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> ) { 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> ) { 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( (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( (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('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('hpc:position', { detail: { position: this.canvasPos, zoom: this.canvasZoom, }, }) ); } }