homemanager-fe/src/modules/house-planner/canvas.ts

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 ? 50 : 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,
},
})
);
}
}