589 lines
16 KiB
TypeScript
589 lines
16 KiB
TypeScript
import { clamp } from '@vueuse/core';
|
|
import extractLinePoints from '../../utils/extract-line-points';
|
|
import { HousePlannerCanvasGrid } from './grid';
|
|
import {
|
|
BezierSegment,
|
|
ICanvasToolkit,
|
|
Layer,
|
|
LayerObject,
|
|
Line,
|
|
LineSegment,
|
|
RepositionEvent,
|
|
Vec2,
|
|
Vec2Box,
|
|
} from './interfaces';
|
|
import { HousePlannerCanvasTools } from './tools';
|
|
import { LayerObjectType } from './types';
|
|
import {
|
|
boundingBox,
|
|
isValidVec2,
|
|
vec2Add,
|
|
vec2AngleFromOrigin,
|
|
vec2Distance,
|
|
vec2DivideScalar,
|
|
vec2Equals,
|
|
vec2MultiplyScalar,
|
|
vec2Snap,
|
|
vec2Sub,
|
|
} from './utils';
|
|
|
|
export class HousePlannerCanvas {
|
|
public ctx!: CanvasRenderingContext2D;
|
|
public layers: Layer[] = [];
|
|
public tools?: ICanvasToolkit;
|
|
public grid = new HousePlannerCanvasGrid(this, 8);
|
|
public mousePosition: Vec2 = [0, 0];
|
|
public mouseClickPosition: Vec2 = [0, 0];
|
|
public mousePositionSnapped: Vec2 = [0, 0];
|
|
public blacklist: LayerObjectType[] = [];
|
|
private dragging = false;
|
|
private pinching = false;
|
|
private lastPinchLength = 0;
|
|
private moved = false;
|
|
private clickedOn: LayerObject | null = null;
|
|
|
|
constructor(
|
|
public canvas: HTMLCanvasElement,
|
|
public canvasDim: Vec2,
|
|
public canvasPos: Vec2,
|
|
public canvasZoom = 1,
|
|
public editable = true,
|
|
public enableGrid = true,
|
|
public headless = false
|
|
) {
|
|
this.ctx = this.canvas.getContext('2d')!;
|
|
this.setupEvents();
|
|
this.resizeCanvas();
|
|
if (editable && !headless) {
|
|
this.tools = new HousePlannerCanvasTools(this);
|
|
}
|
|
|
|
// TODO expose
|
|
if (headless) {
|
|
this.blacklist.push('room');
|
|
}
|
|
}
|
|
|
|
get width() {
|
|
return this.canvas.width;
|
|
}
|
|
|
|
get height() {
|
|
return this.canvas.height;
|
|
}
|
|
|
|
addLayer(layer: Layer) {
|
|
this.layers.push(layer);
|
|
this.draw();
|
|
}
|
|
|
|
resizeCanvas() {
|
|
const [w, h] = this.canvasDim;
|
|
this.canvas.width = w;
|
|
this.canvas.height = h;
|
|
this.draw();
|
|
}
|
|
|
|
cleanUp() {
|
|
if (this.headless) return;
|
|
this.tools?.cleanUp();
|
|
window.removeEventListener('keyup', this.boundKeyUpEvent);
|
|
window.removeEventListener('keydown', this.boundKeyDownEvent);
|
|
}
|
|
|
|
draw() {
|
|
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
this.enableGrid && this.grid.draw();
|
|
this.tools?.drawHighlights();
|
|
for (const layer of this.layers.slice().reverse()) {
|
|
if (!layer.visible) continue;
|
|
if (!layer.contents?.length) continue;
|
|
this.drawLayer(layer);
|
|
}
|
|
this.tools?.drawControls();
|
|
}
|
|
|
|
makeBezier(segment: BezierSegment, path: Path2D) {
|
|
const bezier = segment as BezierSegment;
|
|
const [cp1x, cp1y] = bezier.startControl;
|
|
const [cp2x, cp2y] = bezier.endControl;
|
|
const [x, y] = bezier.end;
|
|
path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
}
|
|
|
|
makeLinePath(line: Line) {
|
|
const path = new Path2D();
|
|
const [firstSegment, ...segments] = line.segments;
|
|
// first segment must have a starting point
|
|
if (!firstSegment.start) return path;
|
|
path.moveTo(...firstSegment.start);
|
|
|
|
if (line.type === 'curve') {
|
|
const lineLength = vec2Distance(firstSegment.start, firstSegment.end);
|
|
const lineAngle = vec2AngleFromOrigin(
|
|
firstSegment.end,
|
|
firstSegment.start!
|
|
);
|
|
const ninety = lineAngle + Math.PI / 2;
|
|
path.moveTo(...firstSegment.end);
|
|
path.arc(
|
|
firstSegment.start[0],
|
|
firstSegment.start[1],
|
|
lineLength,
|
|
lineAngle,
|
|
ninety
|
|
);
|
|
path.lineTo(...firstSegment.start);
|
|
} else if ((firstSegment as BezierSegment).startControl) {
|
|
this.makeBezier(firstSegment as BezierSegment, path);
|
|
} else {
|
|
path.lineTo(...firstSegment.end);
|
|
}
|
|
|
|
for (const segment of segments) {
|
|
if (segment.start) {
|
|
path.moveTo(...segment.start);
|
|
}
|
|
if ((segment as BezierSegment).startControl) {
|
|
this.makeBezier(segment as BezierSegment, path);
|
|
continue;
|
|
}
|
|
|
|
path.lineTo(...segment.end);
|
|
}
|
|
if (line.closed && line.type !== 'curve') {
|
|
path.closePath();
|
|
}
|
|
return path;
|
|
}
|
|
|
|
setupLine(line: Line, overBounds = 0, overrideColor?: string) {
|
|
if (line.lineDash) {
|
|
this.ctx.setLineDash(line.lineDash);
|
|
}
|
|
|
|
this.ctx.strokeStyle = overrideColor || line.color || '#000';
|
|
this.ctx.lineWidth = line.width + overBounds;
|
|
|
|
this.ctx.lineCap = line.lineCap || 'butt';
|
|
this.ctx.lineJoin = line.lineJoin || 'miter';
|
|
}
|
|
|
|
isOnLine(line: Line, x: number, y: number, selectError = 16) {
|
|
let path = line.render || this.makeLinePath(line);
|
|
this.setupLine(line, selectError);
|
|
return this.ctx.isPointInStroke(path, x, y);
|
|
}
|
|
|
|
isOnSegment(
|
|
line: Line,
|
|
segment: LineSegment,
|
|
lastPoint: Vec2,
|
|
x: number,
|
|
y: number,
|
|
selectError = 16
|
|
) {
|
|
const fakePath = this.makeLinePath({
|
|
...line,
|
|
closed: false,
|
|
segments: [{ ...segment, start: lastPoint }],
|
|
});
|
|
this.setupLine(line, selectError);
|
|
return this.ctx.isPointInStroke(fakePath, x, y);
|
|
}
|
|
|
|
getLayerById(layerId: number) {
|
|
return this.layers.find((layer) => layer.id === layerId);
|
|
}
|
|
|
|
getLayerObjectById(layerId: number, objectId: number) {
|
|
const findLayer = this.getLayerById(layerId);
|
|
if (!findLayer) return undefined;
|
|
const findObject = findLayer.contents.find(
|
|
(content) => content.id === objectId
|
|
);
|
|
if (!findObject) return undefined;
|
|
return findObject;
|
|
}
|
|
|
|
updateObjectProperties(
|
|
layerId: number,
|
|
objectId: number,
|
|
properties: Partial<Omit<LayerObject | Line, 'segments'>>
|
|
) {
|
|
const object = this.getLayerObjectById(layerId, objectId);
|
|
if (!object) return;
|
|
this.tools?.history.appendToHistory(
|
|
Object.keys(properties).map((key) => ({
|
|
object,
|
|
property: key,
|
|
value: object[key as keyof typeof object],
|
|
}))
|
|
);
|
|
Object.assign(object, properties);
|
|
this.canvas.dispatchEvent(
|
|
new CustomEvent('hpc:update', {
|
|
detail: { event: 'properties-object', object },
|
|
})
|
|
);
|
|
this.draw();
|
|
return object;
|
|
}
|
|
|
|
updateLayerProperties(
|
|
layerId: number,
|
|
properties: Partial<Omit<Layer, 'contents'>>
|
|
) {
|
|
const layer = this.getLayerById(layerId);
|
|
if (!layer) return;
|
|
this.tools?.history.appendToHistory(
|
|
Object.keys(properties).map((key) => ({
|
|
object: layer,
|
|
property: key,
|
|
value: layer[key as keyof typeof layer],
|
|
}))
|
|
);
|
|
Object.assign(layer, properties);
|
|
this.canvas.dispatchEvent(
|
|
new CustomEvent('hpc:update', {
|
|
detail: { event: 'properties-layer', layer },
|
|
})
|
|
);
|
|
this.draw();
|
|
return layer;
|
|
}
|
|
|
|
translateCanvas(offset: Vec2) {
|
|
const realSize = vec2MultiplyScalar(this.canvasDim, this.canvasZoom);
|
|
this.canvasPos = vec2Sub(this.canvasPos, offset);
|
|
this.repositionEvent();
|
|
}
|
|
|
|
zoomCanvas(diff: number) {
|
|
this.canvasZoom -= diff;
|
|
this.repositionEvent();
|
|
}
|
|
|
|
realMousePos(x: number, y: number, click = false) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
this.mousePosition = [(x - rect.left) * scaleX, (y - rect.top) * scaleY];
|
|
this.mousePositionSnapped = !!this.grid.gridSnap
|
|
? vec2Snap(this.mousePosition, this.grid.gridSnap)
|
|
: this.mousePosition;
|
|
if (click) {
|
|
this.mouseClickPosition = [...this.mousePosition];
|
|
}
|
|
}
|
|
|
|
getAllObjectsOfType(type: LayerObjectType) {
|
|
const objects: LayerObject[] = [];
|
|
this.layers.forEach((layer) => {
|
|
objects.push(...layer.contents.filter((object) => object.type === type));
|
|
});
|
|
return objects;
|
|
}
|
|
|
|
getBoundingBox(): Vec2Box | undefined {
|
|
let box: Vec2Box = [
|
|
[Infinity, Infinity],
|
|
[0, 0],
|
|
];
|
|
|
|
this.layers.forEach((layer) => {
|
|
let layerPoints = layer.contents
|
|
.filter((object) => ['line', 'curve', 'room'].includes(object.type))
|
|
.reduce<Vec2[]>(
|
|
(list, object) => [...list, ...extractLinePoints(object as Line)],
|
|
[]
|
|
);
|
|
if (!layerPoints.length) return;
|
|
box = boundingBox(layerPoints, box);
|
|
});
|
|
|
|
if (
|
|
vec2Distance(box[0], box[1]) < 80 ||
|
|
!isValidVec2(box[0]) ||
|
|
!isValidVec2(box[1])
|
|
)
|
|
return;
|
|
|
|
return box;
|
|
}
|
|
|
|
private drawBoxBounds(box: Vec2Box) {
|
|
for (const point of box) {
|
|
const [x, y] = point;
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
|
this.ctx.fill();
|
|
}
|
|
}
|
|
|
|
private keyDownEvent(e: KeyboardEvent) {
|
|
if (e.target !== document.body && e.target != null) return;
|
|
this.tools?.onKeyDown(e);
|
|
}
|
|
private boundKeyDownEvent = this.keyDownEvent.bind(this);
|
|
private keyUpEvent(e: KeyboardEvent) {
|
|
if (e.target !== document.body && e.target != null) return;
|
|
this.tools?.onKeyUp(e);
|
|
}
|
|
private boundKeyUpEvent = this.keyUpEvent.bind(this);
|
|
|
|
private setupEvents() {
|
|
if (this.headless) return;
|
|
window.addEventListener('keyup', this.boundKeyUpEvent);
|
|
window.addEventListener('keydown', this.boundKeyDownEvent);
|
|
|
|
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
|
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
|
|
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
|
|
|
|
this.canvas.addEventListener('touchmove', (e) => this.onTouchMove(e));
|
|
this.canvas.addEventListener('touchstart', (e) => this.onTouchStart(e));
|
|
this.canvas.addEventListener('touchend', (e) => this.onTouchEnd(e));
|
|
this.canvas.addEventListener('touchcancel', (e) => this.onTouchEnd(e));
|
|
this.canvas.addEventListener('wheel', (e) => this.onMouseWheel(e));
|
|
|
|
this.canvas.addEventListener('pointerleave', () => this.onPointerLeave());
|
|
}
|
|
|
|
private drawRoomText(line: Line) {
|
|
const points = extractLinePoints(line);
|
|
const centerPoint = vec2DivideScalar(
|
|
points.reduce<Vec2 | null>(
|
|
(prev, current) => (prev ? vec2Add(prev, current) : current),
|
|
null
|
|
) as Vec2,
|
|
points.length
|
|
);
|
|
|
|
this.ctx.font = '16px Arial';
|
|
this.ctx.fillStyle = line.color;
|
|
const { width } = this.ctx.measureText(line.name);
|
|
this.ctx.fillText(line.name, centerPoint[0] - width / 2, centerPoint[1]);
|
|
}
|
|
|
|
private drawLine(line: Line) {
|
|
if (this.blacklist?.includes(line.type)) return;
|
|
const path = this.makeLinePath(line);
|
|
line.render = path;
|
|
this.setupLine(line);
|
|
this.ctx.stroke(path);
|
|
if (line.type === 'room') {
|
|
this.drawRoomText(line);
|
|
}
|
|
}
|
|
|
|
private drawLayer(layer: Layer) {
|
|
for (const item of layer.contents) {
|
|
if (!item.visible) continue;
|
|
const line = item as Line;
|
|
if (line.segments) {
|
|
this.drawLine(line);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMouseMove(e: MouseEvent) {
|
|
this.dragEvent(e.clientX, e.clientY);
|
|
}
|
|
onMouseDown(e: MouseEvent) {
|
|
this.mouseClickPosition = [e.clientX, e.clientY];
|
|
this.moved = false;
|
|
|
|
this.pointerDown();
|
|
}
|
|
onMouseUp(e: MouseEvent) {
|
|
this.dragging = false;
|
|
this.pointerUp();
|
|
}
|
|
|
|
onTouchMove(ev: TouchEvent) {
|
|
ev.preventDefault();
|
|
|
|
if (ev.touches.length === 2 && this.pinching) {
|
|
const pinchLength = Math.hypot(
|
|
ev.touches[0].pageX - ev.touches[1].pageX,
|
|
ev.touches[0].pageY - ev.touches[1].pageY
|
|
);
|
|
|
|
if (this.lastPinchLength) {
|
|
const delta = pinchLength / this.lastPinchLength;
|
|
const scaleX =
|
|
(ev.touches[0].clientX - this.canvasPos[0]) / this.canvasZoom;
|
|
const scaleY =
|
|
(ev.touches[0].clientY - this.canvasPos[1]) / this.canvasZoom;
|
|
|
|
delta > 0 ? (this.canvasZoom *= delta) : (this.canvasZoom /= delta);
|
|
this.canvasZoom = clamp(this.canvasZoom, 0.25, 10);
|
|
|
|
this.canvasPos = [
|
|
ev.touches[0].clientX - scaleX * this.canvasZoom,
|
|
ev.touches[0].clientY - scaleY * this.canvasZoom,
|
|
];
|
|
this.repositionEvent();
|
|
}
|
|
this.lastPinchLength = pinchLength;
|
|
}
|
|
|
|
this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY);
|
|
}
|
|
|
|
onTouchStart(e: TouchEvent) {
|
|
e.preventDefault();
|
|
const touch = e.touches[0] || e.changedTouches[0];
|
|
// this.mouseClickPosition = [touch.clientX, touch.clientY];
|
|
this.realMousePos(touch.clientX, touch.clientY, true);
|
|
|
|
this.moved = false;
|
|
|
|
if (e.touches.length === 2) {
|
|
this.pinching = true;
|
|
}
|
|
|
|
this.pointerDown();
|
|
}
|
|
onTouchEnd(e: TouchEvent) {
|
|
this.pinching = false;
|
|
this.lastPinchLength = 0;
|
|
|
|
if (!e.touches?.length) {
|
|
this.dragging = false;
|
|
}
|
|
|
|
this.pointerUp();
|
|
}
|
|
|
|
onPointerLeave() {
|
|
this.dragging = false;
|
|
}
|
|
|
|
setViewRectangle(boundingBox: Vec2Box) {
|
|
if (!isValidVec2(boundingBox[0]) || !isValidVec2(boundingBox[1])) return;
|
|
const [zoom, pos] = this.calculateViewport(boundingBox);
|
|
this.canvasZoom = zoom;
|
|
this.canvasPos = vec2MultiplyScalar(pos, -1);
|
|
this.canvas.dispatchEvent(
|
|
new CustomEvent<RepositionEvent>('hpc:position', {
|
|
detail: {
|
|
position: this.canvasPos,
|
|
zoom: this.canvasZoom,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
private calculateViewport(box: Vec2Box): [number, Vec2] {
|
|
let [min, max] = box;
|
|
const gap = this.headless ? 10 : 80;
|
|
min = vec2Sub(min, [gap, gap]);
|
|
max = vec2Add(max, [gap, gap]);
|
|
|
|
const { width: windowWidth, height: windowHeight } =
|
|
this.canvas.parentElement!.parentElement!.getBoundingClientRect();
|
|
const diagonal = vec2Distance(min, max);
|
|
const target = vec2Sub(max, min);
|
|
const zoomScale = Math.min(windowWidth, windowHeight) / diagonal;
|
|
|
|
let scaledPos = vec2MultiplyScalar(min, zoomScale);
|
|
const scaled = vec2MultiplyScalar(target, zoomScale);
|
|
const overlap = vec2Sub([windowWidth, windowHeight], scaled);
|
|
scaledPos = vec2Sub(scaledPos, vec2DivideScalar(overlap, 2));
|
|
|
|
return [zoomScale, scaledPos];
|
|
}
|
|
|
|
private onMouseWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
this.realMousePos(e.clientX, e.clientY);
|
|
|
|
const scaleX = (e.clientX - this.canvasPos[0]) / this.canvasZoom;
|
|
const scaleY = (e.clientY - this.canvasPos[1]) / this.canvasZoom;
|
|
e.deltaY < 0 ? (this.canvasZoom *= 1.2) : (this.canvasZoom /= 1.2);
|
|
this.canvasZoom = clamp(this.canvasZoom, 0.1, 10);
|
|
this.canvasPos = [
|
|
e.clientX - scaleX * this.canvasZoom,
|
|
e.clientY - scaleY * this.canvasZoom,
|
|
];
|
|
|
|
// this.canvasPos = [
|
|
// clamp(this.canvasPos[0], -window.innerWidth / 2, window.innerWidth / 2),
|
|
// clamp(this.canvasPos[1], -window.innerHeight / 2, window.innerHeight / 2),
|
|
// ];
|
|
this.repositionEvent();
|
|
}
|
|
|
|
private dragEvent(x: number, y: number) {
|
|
this.moved = true;
|
|
|
|
const currentPosAbsolute: Vec2 = [...this.mousePosition];
|
|
const currentPosSnapped: Vec2 = [
|
|
...(!!this.grid.gridSnap
|
|
? this.mousePositionSnapped
|
|
: this.mousePosition),
|
|
];
|
|
|
|
this.realMousePos(x, y);
|
|
let offset = vec2Sub(currentPosSnapped, this.mousePositionSnapped);
|
|
let offsetAbsolute = vec2Sub(currentPosAbsolute, this.mousePosition);
|
|
|
|
this.tools?.mouseMoved(
|
|
this.mousePositionSnapped,
|
|
offset,
|
|
this.mousePosition
|
|
);
|
|
|
|
if (this.dragging) {
|
|
const divided = vec2MultiplyScalar(offsetAbsolute, this.canvasZoom);
|
|
this.translateCanvas(divided);
|
|
// Bias the offset
|
|
const [nx, ny] = vec2Add([x, y], divided);
|
|
this.realMousePos(nx, ny);
|
|
}
|
|
}
|
|
|
|
private pointerDown() {
|
|
if (!this.tools) {
|
|
this.dragging = true;
|
|
return;
|
|
}
|
|
|
|
this.clickedOn = this.tools.getMousedObject();
|
|
this.tools.mouseDown(this.clickedOn || undefined);
|
|
|
|
if (!this.clickedOn) {
|
|
this.dragging = true;
|
|
}
|
|
}
|
|
|
|
private pointerUp() {
|
|
// FIXME: possibly there's a better approach, but right now some clicks do not register
|
|
if (
|
|
this.moved &&
|
|
vec2Distance(this.mouseClickPosition, this.mousePosition) < 1
|
|
) {
|
|
this.moved = false;
|
|
}
|
|
|
|
this.tools?.mouseUp(this.moved);
|
|
|
|
this.dragging = false;
|
|
this.clickedOn = null;
|
|
}
|
|
|
|
private repositionEvent() {
|
|
this.canvas.dispatchEvent(
|
|
new CustomEvent<RepositionEvent>('hpc:position', {
|
|
detail: {
|
|
position: this.canvasPos,
|
|
zoom: this.canvasZoom,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|