fix(lit-components): fix rendering and interaction issues in force-graph, html-table, and draggable-dom
Build and Deploy / build-and-deploy (push) Has been cancelled
Build and Deploy / build-and-deploy (push) Has been cancelled
- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes. - lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode. - lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
@@ -0,0 +1,734 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { Store, BaseNode, NodeEdge, ID } from "../store";
|
||||
import { BaseTreeNode, Tree } from "../ui/tree-view";
|
||||
import { drawLabel } from "./label";
|
||||
import { drawLine, getMidPoint, isPointOnLine } from "./line";
|
||||
import {
|
||||
applyDefaultMatrix,
|
||||
applyMatrix,
|
||||
createMatrix,
|
||||
defaultMatrix,
|
||||
MatrixContext,
|
||||
matrixInfo,
|
||||
toWorld,
|
||||
} from "./matrix";
|
||||
import { drawRect } from "./rect";
|
||||
import { Offset, Rect, Size } from "./utils";
|
||||
|
||||
/**
|
||||
* Editor Canvas
|
||||
*/
|
||||
export class Canvas implements ReactiveController {
|
||||
constructor(
|
||||
public host: ReactiveControllerHost,
|
||||
props?: { canvas?: HTMLCanvasElement; size?: Size }
|
||||
) {
|
||||
this.host.addController(this);
|
||||
this.canvas = props?.canvas ?? document.createElement("canvas");
|
||||
this.ctx = this.canvas.getContext("2d")!;
|
||||
this.ctx.imageSmoothingEnabled = true;
|
||||
this.resize(props?.size);
|
||||
this.init();
|
||||
}
|
||||
canvas: HTMLCanvasElement;
|
||||
store = new Store<CanvasNode>();
|
||||
action: Action = Action.NONE;
|
||||
start?: Offset;
|
||||
end?: Offset;
|
||||
selection = new Array<ID>();
|
||||
pointers: Map<number, Offset> = new Map();
|
||||
mouse: Offset = { x: 0, y: 0 };
|
||||
minScale = 0.1;
|
||||
maxScale = 4.0;
|
||||
gestureEvents = false;
|
||||
lastScale = -1;
|
||||
lastRotation = -1;
|
||||
lastOffset: Offset = { x: 0, y: 0 };
|
||||
rotationEnabled = true;
|
||||
zoomEnabled = true;
|
||||
panEnabled = true;
|
||||
context: MatrixContext = defaultMatrix;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
hostConnected() {}
|
||||
|
||||
hostDisconnected() {}
|
||||
|
||||
nodeTree(): Tree {
|
||||
return {
|
||||
children: this.store.nodes.map(
|
||||
(n) => subTreeForNode(n, this.store, !this.selection.includes(n.id))!
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get size(): Size {
|
||||
return {
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
// Mouse Events
|
||||
this.canvas.addEventListener(
|
||||
"contextmenu",
|
||||
(e) => e.preventDefault(),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener("wheel", (e) => this.onWheel(e), false);
|
||||
|
||||
// Pointer Events
|
||||
this.canvas.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => this.onPointerDown(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"pointerover",
|
||||
(e) => this.onPointerMove(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"pointermove",
|
||||
(e) => this.onPointerMove(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"pointerup",
|
||||
(e) => this.onPointerUp(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"pointerleave",
|
||||
(e) => this.onPointerUp(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"pointercancel",
|
||||
(e) => this.onPointerUp(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"mousemove",
|
||||
(e) => this.onMouseMove(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"mousedown",
|
||||
(e) => this.onMouseDown(e),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e), false);
|
||||
|
||||
// Gesture Events
|
||||
this.canvas.addEventListener(
|
||||
"gesturestart",
|
||||
(e) => this.onGestureStart(e as GestureEvent),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"gesturechange",
|
||||
(e) => this.onGestureChange(e as GestureEvent),
|
||||
false
|
||||
);
|
||||
this.canvas.addEventListener(
|
||||
"gestureend",
|
||||
(e) => this.onGestureEnd(e as GestureEvent),
|
||||
false
|
||||
);
|
||||
|
||||
// Keyboard Events
|
||||
window.addEventListener("keydown", (e) => this.onKeyDown(e));
|
||||
// window.addEventListener("mousewheel", (e) => {
|
||||
// e.preventDefault();
|
||||
// });
|
||||
window.addEventListener("DOMMouseScroll", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// TODO: https://github.com/shuding/apple-pencil-safari-api-test
|
||||
|
||||
this.render();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.paint();
|
||||
requestAnimationFrame(() => this.render());
|
||||
}
|
||||
|
||||
onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (this.gestureEvents) return;
|
||||
if (e.ctrlKey) {
|
||||
this.action = Action.ZOOM;
|
||||
const scale = -e.deltaY * 0.01;
|
||||
this.zoom(scale);
|
||||
} else {
|
||||
this.action = Action.PAN;
|
||||
const offset = { x: -e.deltaX * 2, y: -e.deltaY * 2 };
|
||||
this.pan(offset);
|
||||
}
|
||||
this.host.requestUpdate();
|
||||
this.action = Action.NONE;
|
||||
}
|
||||
|
||||
onGestureStart(e: GestureEvent) {
|
||||
e.preventDefault();
|
||||
this.gestureEvents = true;
|
||||
this.lastScale = e.scale;
|
||||
this.lastRotation = e.rotation;
|
||||
this.lastOffset = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
onGestureChange(e: GestureEvent) {
|
||||
e.preventDefault();
|
||||
this.gestureEvents = true;
|
||||
const { scale, offset, rotation } = matrixInfo(this.context);
|
||||
let localRotation = rotation;
|
||||
let localScale = scale;
|
||||
let localOffset = offset;
|
||||
|
||||
const rotationDelta = (this.lastRotation - e.rotation) * 0.01;
|
||||
localRotation -= rotationDelta;
|
||||
this.lastRotation = e.rotation;
|
||||
|
||||
const scaleDelta = (this.lastScale - e.scale) * 1;
|
||||
localScale -= scaleDelta;
|
||||
this.lastScale = e.scale;
|
||||
|
||||
const offsetDelta = {
|
||||
x: (this.lastOffset.x - e.clientX) * 0.01,
|
||||
y: (this.lastOffset.y - e.clientY) * 0.01,
|
||||
};
|
||||
localOffset.x -= offsetDelta.x * localScale;
|
||||
localOffset.y -= offsetDelta.y * localScale;
|
||||
this.lastOffset = { x: e.clientX, y: e.clientY };
|
||||
|
||||
this.context = createMatrix(localOffset, localScale, localRotation);
|
||||
|
||||
this.host.requestUpdate();
|
||||
applyMatrix(this.ctx, this.context);
|
||||
}
|
||||
|
||||
onGestureEnd(e: GestureEvent) {
|
||||
e.preventDefault();
|
||||
this.gestureEvents = false;
|
||||
}
|
||||
|
||||
onMouseUp(e: MouseEvent) {
|
||||
this.mouse = { x: e.offsetX, y: e.offsetY };
|
||||
}
|
||||
|
||||
onMouseDown(e: MouseEvent) {
|
||||
this.mouse = { x: e.offsetX, y: e.offsetY };
|
||||
}
|
||||
|
||||
onMouseMove(e: MouseEvent) {
|
||||
this.mouse = { x: e.offsetX, y: e.offsetY };
|
||||
}
|
||||
|
||||
onPointerDown(e: PointerEvent) {
|
||||
const point = { x: e.offsetX, y: e.offsetY };
|
||||
this.pointers.set(e.pointerId, point);
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
this.start = point;
|
||||
this.end = point;
|
||||
const mouseOffset = toWorld(this.context, point);
|
||||
if (this.selection.length > 1) {
|
||||
this.action = Action.MOVE;
|
||||
return;
|
||||
}
|
||||
const nodes = this.selectOffset(mouseOffset, this.selection, e.shiftKey);
|
||||
if (nodes.length === 0) {
|
||||
this.selection = [];
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
this.action =
|
||||
nodes.length > 0
|
||||
? e.shiftKey
|
||||
? Action.LINK
|
||||
: Action.MOVE
|
||||
: Action.MARQUEE;
|
||||
if (nodes.length > 0) {
|
||||
const nodeId = nodes[nodes.length - 1];
|
||||
if (this.action === Action.MOVE) {
|
||||
this.selection = [nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPointerMove(e: PointerEvent) {
|
||||
const point = { x: e.offsetX, y: e.offsetY };
|
||||
const pointerId = e.pointerId;
|
||||
const pointer = this.pointers.get(pointerId);
|
||||
if (pointer) {
|
||||
const delta = {
|
||||
x: point.x - pointer.x,
|
||||
y: point.y - pointer.y,
|
||||
};
|
||||
this.end = point;
|
||||
if (this.action === Action.MOVE) {
|
||||
for (const id of this.selection) {
|
||||
const node = this.store.retrieveNode(id);
|
||||
if (node) {
|
||||
this.moveNode(node, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pointers.set(pointerId, point);
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(e: PointerEvent) {
|
||||
this.canvas.releasePointerCapture(e.pointerId);
|
||||
this.pointers.delete(e.pointerId);
|
||||
if (this.start && this.end) {
|
||||
const start = toWorld(this.context, this.start);
|
||||
const end = toWorld(this.context, this.end);
|
||||
const topLeft = {
|
||||
x: Math.min(start.x, end.x),
|
||||
y: Math.min(start.y, end.y),
|
||||
};
|
||||
const bottomRight = {
|
||||
x: Math.max(start.x, end.x),
|
||||
y: Math.max(start.y, end.y),
|
||||
};
|
||||
if (this.action === Action.LINK) {
|
||||
const multi = e.shiftKey;
|
||||
const startType = this.checkOffsetType(start, multi);
|
||||
const endType = this.checkOffsetType(end, multi);
|
||||
if (startType === "node" && endType === "node") {
|
||||
const startNode = this.getOffset(start, multi) as CanvasNode;
|
||||
const endNode = this.getOffset(end, multi) as CanvasNode;
|
||||
this.store.linkNodes(startNode, endNode, "simple");
|
||||
}
|
||||
} else if (this.action === Action.MARQUEE) {
|
||||
if (e.shiftKey) {
|
||||
this.selection = [];
|
||||
}
|
||||
const overlappingRect: Rect = {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y,
|
||||
};
|
||||
// Check if any nodes are selected by overlapping rect
|
||||
const nodes = this.store.nodes;
|
||||
const selectedNodes: CanvasNode[] = [];
|
||||
for (const node of nodes) {
|
||||
// Check if node overlaps rect
|
||||
const nodeRect = {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
};
|
||||
if (
|
||||
nodeRect.x < overlappingRect.x + overlappingRect.width &&
|
||||
nodeRect.x + nodeRect.width > overlappingRect.x &&
|
||||
nodeRect.y < overlappingRect.y + overlappingRect.height &&
|
||||
nodeRect.y + nodeRect.height > overlappingRect.y
|
||||
) {
|
||||
selectedNodes.push(node);
|
||||
}
|
||||
}
|
||||
for (const node of selectedNodes) {
|
||||
this.selection.push(node.id);
|
||||
}
|
||||
} else {
|
||||
this.selection = this.selectOffset(end, this.selection, e.shiftKey);
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
this.start = undefined;
|
||||
this.end = undefined;
|
||||
this.action = Action.NONE;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
getOffset(offset: Offset, multi: boolean): CanvasNode | undefined {
|
||||
const selection = this.selectOffset(offset, this.selection, multi);
|
||||
if (selection.length > 1) {
|
||||
const node = this.store.retrieveNode(selection[selection.length - 1]);
|
||||
if (node) return node;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
checkOffsetType(offset: Offset, multi: boolean): "none" | "node" | "link" {
|
||||
const node = this.getOffset(offset, multi);
|
||||
if (node) return node.type;
|
||||
return "none";
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
// Delete current selection
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
this.deleteSelection();
|
||||
}
|
||||
// Zoom canvas
|
||||
const zoomStep = 0.1;
|
||||
if (e.key === "+" || e.key === "=") {
|
||||
this.zoom(zoomStep);
|
||||
}
|
||||
if (e.key === "-") {
|
||||
this.zoom(-zoomStep);
|
||||
}
|
||||
|
||||
// Move with arrow keys
|
||||
const moveStep = 10;
|
||||
if (e.key === "ArrowUp") {
|
||||
this.pan({ x: 0, y: -moveStep });
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
this.pan({ x: 0, y: moveStep });
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
this.pan({ x: -moveStep, y: 0 });
|
||||
}
|
||||
if (e.key === "ArrowRight") {
|
||||
this.pan({ x: moveStep, y: 0 });
|
||||
}
|
||||
|
||||
// Select all
|
||||
if (e.key === "a" && e.metaKey) {
|
||||
this.selection = this.store.nodes.map((n) => n.id);
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
zoom(amount: number) {
|
||||
const { scale, offset, rotation } = matrixInfo(this.context);
|
||||
let localScale = scale;
|
||||
localScale += amount;
|
||||
this.context = createMatrix(offset, localScale, rotation);
|
||||
}
|
||||
|
||||
pan(delta: Offset) {
|
||||
const { offset, scale, rotation } = matrixInfo(this.context);
|
||||
let localOffset = offset;
|
||||
localOffset.x += delta.x / scale;
|
||||
localOffset.y += delta.y / scale;
|
||||
this.context = createMatrix(localOffset, scale, rotation);
|
||||
}
|
||||
|
||||
rotate(amount: number) {
|
||||
const { rotation, offset, scale } = matrixInfo(this.context);
|
||||
let localRotation = rotation;
|
||||
localRotation += amount;
|
||||
this.context = createMatrix(offset, scale, localRotation);
|
||||
}
|
||||
|
||||
import(value: string) {
|
||||
this.store = Store.fromJson<CanvasNode>(value);
|
||||
}
|
||||
|
||||
deleteNode(node: CanvasNode) {
|
||||
this.store.deleteNode(node.id);
|
||||
this.clear();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
deleteEdge(edge: NodeEdge) {
|
||||
this.store.deleteEdge(edge.id);
|
||||
this.clear();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resize(size?: Size) {
|
||||
const width = size?.width ?? window.innerWidth;
|
||||
const height = size?.height ?? window.innerHeight;
|
||||
this.canvas.setAttribute("width", `${width}px`);
|
||||
this.canvas.setAttribute("height", `${height}px`);
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
deleteSelection() {
|
||||
console.log("delete selection", this.selection);
|
||||
for (const id of this.selection) {
|
||||
const node = this.store.retrieveNode(id);
|
||||
if (node) this.store.deleteNode(node.id);
|
||||
const edge = this.store.retrieveEdge(id);
|
||||
if (edge) this.store.deleteEdge(edge.id);
|
||||
}
|
||||
this.selection = [];
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
selectOffset(offset: Offset, nodes: string[], multi: boolean) {
|
||||
let selection = [...nodes];
|
||||
// Clear selection
|
||||
if (!multi) selection = [];
|
||||
|
||||
// Check current offset for selection
|
||||
const overlappedNodes = this.store.nodes.filter((node) => {
|
||||
const overlaps =
|
||||
offset.x >= node.x &&
|
||||
offset.x <= node.x + node.width &&
|
||||
offset.y >= node.y &&
|
||||
offset.y <= node.y + node.height;
|
||||
return overlaps;
|
||||
});
|
||||
|
||||
// Select nodes
|
||||
if (overlappedNodes.length > 0) {
|
||||
const topNode = overlappedNodes[overlappedNodes.length - 1];
|
||||
selection.push(topNode.id);
|
||||
}
|
||||
|
||||
// If shift is not selected return early
|
||||
if (!multi && selection.length > 0) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
// Select edges
|
||||
const overlappedEdges = this.store.edges.filter((edge) => {
|
||||
const { start, end } = this.getEdgePoints(edge);
|
||||
const overlaps = isPointOnLine(start, end, offset);
|
||||
return overlaps;
|
||||
});
|
||||
if (overlappedEdges.length > 0) {
|
||||
const topEdge = overlappedEdges[overlappedEdges.length - 1];
|
||||
selection.push(topEdge.id);
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
get scale(): number {
|
||||
const { scale } = matrixInfo(this.context);
|
||||
return scale;
|
||||
}
|
||||
|
||||
get rotation(): number {
|
||||
const { rotation } = matrixInfo(this.context);
|
||||
return rotation;
|
||||
}
|
||||
|
||||
get offset(): Offset {
|
||||
const { offset } = matrixInfo(this.context);
|
||||
return offset;
|
||||
}
|
||||
|
||||
moveNode(node: CanvasNode, delta: Offset) {
|
||||
const { scale } = matrixInfo(this.context);
|
||||
node.x += delta.x / scale;
|
||||
node.y += delta.y / scale;
|
||||
this.store.updateNode(node);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selection = [];
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
paint() {
|
||||
applyDefaultMatrix(this.ctx);
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
drawRect(this.ctx, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
fillColor: "whitesmoke",
|
||||
});
|
||||
|
||||
applyMatrix(this.ctx, this.context);
|
||||
|
||||
const mouse = this.mouse;
|
||||
const mouseOffset = toWorld(this.context, mouse);
|
||||
|
||||
// Draw edges
|
||||
for (const edge of this.store.edges) {
|
||||
const isSelected = this.selection.includes(edge.id);
|
||||
const { start, end, mid } = this.getEdgePoints(edge);
|
||||
|
||||
// Draw line
|
||||
const ctx = this.ctx;
|
||||
drawLine(ctx, {
|
||||
start,
|
||||
end,
|
||||
strokeColor: isSelected ? "red" : "black",
|
||||
});
|
||||
|
||||
// Check if point is anywhere on the line
|
||||
const overlaps = isPointOnLine(start, end, mouseOffset);
|
||||
if (overlaps && !isSelected) {
|
||||
ctx.strokeStyle = "blue";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw label
|
||||
drawLabel(this.ctx, {
|
||||
text: edge.name,
|
||||
textAlign: "center",
|
||||
x: mid.x,
|
||||
y: mid.y,
|
||||
fillColor: "black",
|
||||
});
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (const node of this.store.nodes) {
|
||||
const isSelected = this.selection.includes(node.id);
|
||||
|
||||
const ctx = this.ctx;
|
||||
// Draw label
|
||||
drawLabel(ctx, {
|
||||
text: node.name,
|
||||
x: node.x,
|
||||
y: node.y - 5,
|
||||
fillColor: "black",
|
||||
});
|
||||
|
||||
drawRect(ctx, {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
strokeColor: isSelected ? "red" : "black",
|
||||
fillColor: "white",
|
||||
});
|
||||
|
||||
const overlaps =
|
||||
mouseOffset.x >= node.x &&
|
||||
mouseOffset.x <= node.x + node.width &&
|
||||
mouseOffset.y >= node.y &&
|
||||
mouseOffset.y <= node.y + node.height;
|
||||
if (overlaps && !isSelected) {
|
||||
ctx.strokeStyle = "blue";
|
||||
ctx.strokeRect(node.x, node.y, node.width, node.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.start && this.end) {
|
||||
const localStart = toWorld(this.context, this.start);
|
||||
const localEnd = toWorld(this.context, this.end);
|
||||
|
||||
if (this.action == Action.LINK) {
|
||||
// Render line to link
|
||||
drawLine(this.ctx, {
|
||||
start: localStart,
|
||||
end: localEnd,
|
||||
strokeColor: "red",
|
||||
});
|
||||
}
|
||||
if (this.action == Action.MARQUEE) {
|
||||
// Render marquee
|
||||
drawRect(this.ctx, {
|
||||
x: Math.min(localStart.x, localEnd.x),
|
||||
y: Math.min(localStart.y, localEnd.y),
|
||||
width: Math.abs(localStart.x - localEnd.x),
|
||||
height: Math.abs(localStart.y - localEnd.y),
|
||||
fillColor: "rgba(135, 206, 235, 0.2)",
|
||||
strokeColor: "rgba(135, 206, 235, 0.5)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selection.length > 1) {
|
||||
const nodes = this.store.nodes.filter((node) =>
|
||||
this.selection.includes(node.id)
|
||||
);
|
||||
const topY = Math.min(...nodes.map((node) => node.y));
|
||||
const leftX = Math.min(...nodes.map((node) => node.x));
|
||||
const bottomY = Math.max(...nodes.map((node) => node.y + node.height));
|
||||
const rightX = Math.max(...nodes.map((node) => node.x + node.width));
|
||||
const topLeft: Offset = { x: leftX, y: topY };
|
||||
const bottomRight: Offset = { x: rightX, y: bottomY };
|
||||
|
||||
if (topLeft && bottomRight) {
|
||||
drawRect(this.ctx, {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y,
|
||||
fillColor: "transparent",
|
||||
strokeColor: "blue",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getEdgePoints(edge: NodeEdge) {
|
||||
const startNode = this.store.retrieveNode(edge.startNode)!;
|
||||
const endNode = this.store.retrieveNode(edge.endNode)!;
|
||||
const createOffset = (node: CanvasNode) => ({
|
||||
x: node.x + node.width / 2,
|
||||
y: node.y + node.height / 2,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
});
|
||||
const start = createOffset(startNode);
|
||||
const end = createOffset(endNode);
|
||||
const mid = getMidPoint(start, end);
|
||||
return { start, end, mid };
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
NONE,
|
||||
ZOOM,
|
||||
PAN,
|
||||
MOVE,
|
||||
LINK,
|
||||
MARQUEE,
|
||||
}
|
||||
|
||||
type PositionMixin = BaseNode & Rect;
|
||||
|
||||
/**
|
||||
* Canvas Node
|
||||
*/
|
||||
export interface CanvasNode extends PositionMixin {
|
||||
backgroundColor?: string;
|
||||
type: "node";
|
||||
}
|
||||
|
||||
interface GestureEvent extends MouseEvent {
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
function subTreeForNode(
|
||||
node: CanvasNode,
|
||||
store: Store<CanvasNode>,
|
||||
collapsed: boolean = false,
|
||||
lookup: Map<string, boolean> = new Map()
|
||||
): BaseTreeNode | null {
|
||||
if (lookup.has(node.id)) {
|
||||
return null;
|
||||
}
|
||||
lookup.set(node.id, true);
|
||||
|
||||
const children: BaseTreeNode[] = [];
|
||||
const edges = store.retrieveEdgesForNode(node.id);
|
||||
for (const edge of edges) {
|
||||
const edgeChildren: BaseTreeNode[] = [];
|
||||
|
||||
const endNode = store.retrieveNode(edge.endNode)!;
|
||||
const endSubTree = subTreeForNode(endNode, store, collapsed, lookup);
|
||||
if (endSubTree) edgeChildren.push(endSubTree);
|
||||
|
||||
const startNode = store.retrieveNode(edge.startNode)!;
|
||||
const startSubTree = subTreeForNode(startNode, store, collapsed, lookup);
|
||||
if (startSubTree) edgeChildren.push(startSubTree);
|
||||
|
||||
children.push({
|
||||
id: edge.id,
|
||||
name: edge.name,
|
||||
children: edgeChildren,
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
children,
|
||||
collapsed: collapsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ColorMixin, Offset, Size } from "./utils";
|
||||
|
||||
interface LabelOptions extends Offset, ColorMixin {
|
||||
text: string;
|
||||
maxWidth?: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontStyle?: "normal" | "italic" | "oblique" | "initial" | "inherit";
|
||||
fontWeight?: string;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
lineHeight?: number;
|
||||
textBaseline?:
|
||||
| "top"
|
||||
| "hanging"
|
||||
| "middle"
|
||||
| "alphabetic"
|
||||
| "ideographic"
|
||||
| "bottom";
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_HEIGHT = 8;
|
||||
|
||||
export function drawLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: LabelOptions
|
||||
): Size {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
maxWidth,
|
||||
fontFamily = "Roboto",
|
||||
fontSize = DEFAULT_TEXT_HEIGHT,
|
||||
fontStyle = "normal",
|
||||
fontWeight = "normal",
|
||||
textAlign = "left",
|
||||
textBaseline = "alphabetic",
|
||||
lineHeight = 1.2,
|
||||
text,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
} = options;
|
||||
ctx.save();
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${lineHeight}em ${fontFamily}`;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = textBaseline;
|
||||
|
||||
if (fillColor) {
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillText(text, x, y, maxWidth);
|
||||
}
|
||||
|
||||
if (strokeColor) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.strokeText(text, x, y, maxWidth);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Measure the text
|
||||
const metrics = ctx.measureText(options.text);
|
||||
const height = fontSize * lineHeight;
|
||||
return { height, width: metrics.width };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ColorMixin, Offset } from "./utils";
|
||||
|
||||
interface LineOptions extends ColorMixin {
|
||||
start: Offset;
|
||||
end: Offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a line between two points
|
||||
*
|
||||
* @param ctx Canvas context
|
||||
* @param options Line options
|
||||
*/
|
||||
export function drawLine(ctx: CanvasRenderingContext2D, options: LineOptions) {
|
||||
const { start, end, fillColor, strokeColor } = options;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
ctx.lineTo(end.x, end.y);
|
||||
|
||||
if (fillColor) {
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (strokeColor) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is inside a line
|
||||
*
|
||||
* @param start Start point
|
||||
* @param end End point
|
||||
* @param target Offset to check
|
||||
* @param tolerance Tolerance
|
||||
*/
|
||||
export function isPointOnLine(
|
||||
start: Offset,
|
||||
end: Offset,
|
||||
target: Offset,
|
||||
tolerance = 1
|
||||
): boolean {
|
||||
// if line is vertical
|
||||
if (start.x === end.x) {
|
||||
return Math.abs(target.x - start.x) <= tolerance;
|
||||
}
|
||||
|
||||
// if line is horizontal
|
||||
if (start.y === end.y) {
|
||||
return Math.abs(target.y - start.y) <= tolerance;
|
||||
}
|
||||
|
||||
// if line is diagonal
|
||||
const slope = (end.y - start.y) / (end.x - start.x);
|
||||
const intercept = start.y - slope * start.x;
|
||||
const y = slope * target.x + intercept;
|
||||
return Math.abs(y - target.y) <= tolerance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the midpoint of a line
|
||||
*
|
||||
* @param start Start point
|
||||
* @param end End point
|
||||
* @returns Midpoint
|
||||
*/
|
||||
export function getMidPoint(start: Offset, end: Offset): Offset {
|
||||
return {
|
||||
x: (start.x + end.x) / 2,
|
||||
y: (start.y + end.y) / 2,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Offset } from "./utils";
|
||||
|
||||
/**
|
||||
* Matrix
|
||||
*/
|
||||
export type Matrix = number[];
|
||||
|
||||
const DEFAULT_MATRIX: Matrix = [1, 0, 0, 1, 0, 0];
|
||||
const DEFAULT_INVERSE_MATRIX: Matrix = [1, 0, 0, 1];
|
||||
|
||||
/**
|
||||
* Matrix context
|
||||
*/
|
||||
export interface MatrixContext {
|
||||
matrix: Matrix;
|
||||
inverseMatrix: Matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default matrix context
|
||||
*/
|
||||
export const defaultMatrix = createMatrix({ x: 0, y: 0 }, 1, 0);
|
||||
|
||||
/**
|
||||
* Create a matrix and inverse matrix
|
||||
*
|
||||
* @param offset - offset
|
||||
* @param scale - scale factor
|
||||
* @param rotation - in radians
|
||||
*
|
||||
* @link https://stackoverflow.com/a/34598847/7303311
|
||||
*/
|
||||
export function createMatrix(
|
||||
offset: Offset,
|
||||
scale: number,
|
||||
rotation: number
|
||||
): MatrixContext {
|
||||
const m = [...DEFAULT_MATRIX];
|
||||
const im = [...DEFAULT_INVERSE_MATRIX];
|
||||
m[3] = m[0] = Math.cos(rotation) * scale;
|
||||
m[2] = -(m[1] = Math.sin(rotation) * scale);
|
||||
m[4] = offset.x;
|
||||
m[5] = offset.y;
|
||||
const cross = m[0] * m[3] - m[1] * m[2];
|
||||
im[0] = m[3] / cross;
|
||||
im[1] = -m[1] / cross;
|
||||
im[2] = -m[2] / cross;
|
||||
im[3] = m[0] / cross;
|
||||
return {
|
||||
matrix: m,
|
||||
inverseMatrix: im,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local offset from a global offset
|
||||
*
|
||||
* @param matrix - matrix
|
||||
* @param inverseMatrix - inverse matrix
|
||||
* @param offset - offset
|
||||
*
|
||||
* @link https://stackoverflow.com/a/34598847/7303311
|
||||
*/
|
||||
export function toWorld(context: MatrixContext, offset: Offset): Offset {
|
||||
let xx, yy, m;
|
||||
m = context.inverseMatrix;
|
||||
xx = offset.x - context.matrix[4];
|
||||
yy = offset.y - context.matrix[5];
|
||||
const localX = xx * m[0] + yy * m[2];
|
||||
const localY = xx * m[1] + yy * m[3];
|
||||
return {
|
||||
x: localX,
|
||||
y: localY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix info
|
||||
*
|
||||
* @param matrix - matrix
|
||||
* @returns scale, offset, rotation
|
||||
*/
|
||||
export function matrixInfo(context: MatrixContext) {
|
||||
const rotation = rotationFromMatrix(context);
|
||||
const { scale } = scaleFromMatrix(context);
|
||||
const offset = offsetFromMatrix(context);
|
||||
return {
|
||||
rotation,
|
||||
scale,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rotation from matrix
|
||||
*
|
||||
* @param matrix - matrix
|
||||
* @returns rotation in radians
|
||||
*/
|
||||
export function rotationFromMatrix(context: MatrixContext) {
|
||||
const matrix = context.matrix;
|
||||
const rad = Math.atan2(matrix[1], matrix[0]);
|
||||
return rad;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scale factor from matrix
|
||||
*
|
||||
* @param matrix - matrix
|
||||
* @returns scale factor
|
||||
*/
|
||||
export function scaleFromMatrix(context: MatrixContext): {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scale: number;
|
||||
} {
|
||||
const matrix = context.matrix;
|
||||
const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]);
|
||||
const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]);
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
scale: Math.max(scaleX, scaleY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offset from matrix
|
||||
*
|
||||
* @param matrix - matrix
|
||||
* @returns offset
|
||||
*/
|
||||
export function offsetFromMatrix(context: MatrixContext) {
|
||||
const matrix = context.matrix;
|
||||
return {
|
||||
x: matrix[4],
|
||||
y: matrix[5],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply matrix to canvas
|
||||
*
|
||||
* @param ctx - canvas context
|
||||
* @param matrix - matrix
|
||||
*/
|
||||
export function applyMatrix(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
context: MatrixContext
|
||||
) {
|
||||
const m = context.matrix;
|
||||
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default matrix to canvas
|
||||
*
|
||||
* @param ctx - canvas context
|
||||
*/
|
||||
export function applyDefaultMatrix(ctx: CanvasRenderingContext2D) {
|
||||
const m = DEFAULT_MATRIX;
|
||||
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matrix from canvas
|
||||
*
|
||||
* @param ctx - canvas context
|
||||
* @returns Matrix context
|
||||
*/
|
||||
export function getMatrixFromCanvas(
|
||||
ctx: CanvasRenderingContext2D
|
||||
): MatrixContext {
|
||||
const transform = ctx.getTransform();
|
||||
const m = [...DEFAULT_MATRIX];
|
||||
const im = [...DEFAULT_INVERSE_MATRIX];
|
||||
m[0] = transform.a;
|
||||
m[1] = transform.b;
|
||||
m[2] = transform.c;
|
||||
m[3] = transform.d;
|
||||
m[4] = transform.e;
|
||||
m[5] = transform.f;
|
||||
const cross = m[0] * m[3] - m[1] * m[2];
|
||||
im[0] = m[3] / cross;
|
||||
im[1] = -m[1] / cross;
|
||||
im[2] = -m[2] / cross;
|
||||
im[3] = m[0] / cross;
|
||||
return {
|
||||
matrix: m,
|
||||
inverseMatrix: im,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ColorMixin, Rect } from "./utils";
|
||||
|
||||
interface RectOptions extends Rect, ColorMixin {}
|
||||
|
||||
/**
|
||||
* Draw a rectangle on the canvas
|
||||
*
|
||||
* @param ctx Canvas context
|
||||
* @param options Options for the rectangle
|
||||
*/
|
||||
export function drawRect(ctx: CanvasRenderingContext2D, options: RectOptions) {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
width = 0,
|
||||
height = 0,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
} = options;
|
||||
ctx.rect(x, y, width, height);
|
||||
|
||||
if (fillColor) {
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(x, y, width, height);
|
||||
}
|
||||
|
||||
if (strokeColor) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* X and y
|
||||
*/
|
||||
export interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width and height
|
||||
*/
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect with offset and size
|
||||
*/
|
||||
export type Rect = Offset & Size;
|
||||
|
||||
/**
|
||||
* Color for outline and fill
|
||||
*/
|
||||
export interface ColorMixin {
|
||||
fillColor?: string;
|
||||
strokeColor?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user