ai-chatbot/lib/canvas/arrange.ts

316 lines
9.1 KiB
TypeScript

import type { ELK, ElkNode } from "elkjs/lib/elk.bundled.js";
export const CANVAS_ARRANGE_NODE_GAP = 100;
export const CANVAS_ARRANGE_COMPONENT_GAP = 1.5 * CANVAS_ARRANGE_NODE_GAP;
export const CANVAS_ARRANGE_MAX_ROW_WIDTH = 3200;
export const CANVAS_ARRANGE_SNAP_GRID_SIZE = 12;
export const CANVAS_ARRANGE_VIEWPORT_OFFSET = 48;
export const CANVAS_ARRANGE_VIEWPORT_PADDING = 0.12;
export type CanvasArrangeNode = {
height: number;
id: string;
parentId?: string;
width: number;
x: number;
y: number;
};
export type CanvasArrangeEdge = {
source: string;
target: string;
};
export type CanvasArrangePositionUpdate = {
id: string;
position: { x: number; y: number };
};
type LayoutComponent = {
height: number;
positions: Map<string, { x: number; y: number }>;
width: number;
};
let elkPromise: Promise<ELK> | null = null;
function getElk() {
if (!elkPromise) {
elkPromise = import("elkjs/lib/elk.bundled.js")
.then(({ default: Elk }) => new Elk())
.catch((error) => {
elkPromise = null;
throw error;
});
}
return elkPromise;
}
function snapArrangeCoordinate(value: number) {
return Math.round(value / CANVAS_ARRANGE_SNAP_GRID_SIZE) * CANVAS_ARRANGE_SNAP_GRID_SIZE;
}
function getRootNodeId(nodeId: string, nodesById: Map<string, CanvasArrangeNode>) {
let currentId = nodeId;
const visited = new Set<string>();
for (let index = 0; index < 256; index += 1) {
const node = nodesById.get(currentId);
if (!node?.parentId || visited.has(currentId)) break;
visited.add(currentId);
currentId = node.parentId;
}
return currentId;
}
function groupConnectedRootNodes(
nodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[],
nodesById: Map<string, CanvasArrangeNode>
) {
const rootNodes = nodes.filter((node) => !node.parentId);
if (rootNodes.length === 0) return [];
const rootIds = new Set(rootNodes.map((node) => node.id));
const parents = new Map(rootNodes.map((node) => [node.id, node.id]));
const find = (nodeId: string) => {
let rootId = nodeId;
while (parents.get(rootId) !== rootId) {
rootId = parents.get(rootId) || rootId;
}
let currentId = nodeId;
while (parents.get(currentId) !== rootId) {
const nextId = parents.get(currentId);
parents.set(currentId, rootId);
if (!nextId) break;
currentId = nextId;
}
return rootId;
};
for (const edge of edges) {
const sourceId = getRootNodeId(edge.source, nodesById);
const targetId = getRootNodeId(edge.target, nodesById);
if (sourceId === targetId || !rootIds.has(sourceId) || !rootIds.has(targetId)) continue;
const sourceRoot = find(sourceId);
const targetRoot = find(targetId);
if (sourceRoot !== targetRoot) parents.set(sourceRoot, targetRoot);
}
const groups = new Map<string, CanvasArrangeNode[]>();
for (const node of rootNodes) {
const rootId = find(node.id);
const group = groups.get(rootId) || [];
group.push(node);
groups.set(rootId, group);
}
const sortedGroups = [...groups.values()].map((group) =>
group.sort((first, second) => {
const yDifference = first.y - second.y;
return Math.abs(yDifference) > 1 ? yDifference : first.x - second.x;
})
);
return sortedGroups.sort((first, second) => {
const firstY = Math.min(...first.map((node) => node.y));
const secondY = Math.min(...second.map((node) => node.y));
if (Math.abs(firstY - secondY) > 1) return firstY - secondY;
return (
Math.min(...first.map((node) => node.x)) -
Math.min(...second.map((node) => node.x))
);
});
}
async function layoutComponent(
componentNodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[],
nodesById: Map<string, CanvasArrangeNode>
): Promise<LayoutComponent | null> {
const componentIds = new Set(componentNodes.map((node) => node.id));
const seenEdges = new Set<string>();
const layoutEdges: NonNullable<ElkNode["edges"]> = [];
for (const edge of edges) {
const sourceId = getRootNodeId(edge.source, nodesById);
const targetId = getRootNodeId(edge.target, nodesById);
if (
sourceId === targetId ||
!componentIds.has(sourceId) ||
!componentIds.has(targetId)
) {
continue;
}
const edgeKey = `${sourceId}->${targetId}`;
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
layoutEdges.push({
id: `e-${sourceId}-${targetId}`,
sources: [sourceId],
targets: [targetId],
});
}
const graph: ElkNode = {
children: componentNodes.map((node) => ({
height: Math.max(1, node.height),
id: node.id,
width: Math.max(1, node.width),
})),
edges: layoutEdges,
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.layered.spacing.nodeNodeBetweenLayers": String(2 * CANVAS_ARRANGE_NODE_GAP),
"elk.padding": "[ top=0, left=0, bottom=0, right=0 ]",
"elk.spacing.nodeNode": String(CANVAS_ARRANGE_NODE_GAP),
},
};
let layout: ElkNode;
try {
layout = await (await getElk()).layout(graph);
} catch {
return null;
}
const positions = new Map<string, { x: number; y: number }>();
for (const child of layout.children || []) {
if (!child.id) continue;
positions.set(child.id, { x: child.x || 0, y: child.y || 0 });
}
const minX = Math.min(...[...positions.values()].map((position) => position.x));
const minY = Math.min(...[...positions.values()].map((position) => position.y));
if (Number.isFinite(minX) && Number.isFinite(minY)) {
for (const position of positions.values()) {
position.x -= minX;
position.y -= minY;
}
}
let width = 0;
let height = 0;
for (const [nodeId, position] of positions) {
const node = nodesById.get(nodeId);
if (!node) continue;
width = Math.max(width, position.x + node.width);
height = Math.max(height, position.y + node.height);
}
return positions.size > 0 ? { height, positions, width } : null;
}
export async function arrangeCanvasNodes(
nodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[]
): Promise<CanvasArrangePositionUpdate[]> {
const rootNodes = nodes.filter((node) => !node.parentId);
if (rootNodes.length <= 1) return [];
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const groups = groupConnectedRootNodes(nodes, edges, nodesById);
const components: LayoutComponent[] = [];
for (const group of groups) {
const component = await layoutComponent(group, edges, nodesById);
if (component) components.push(component);
}
const originX = snapArrangeCoordinate(Math.min(...rootNodes.map((node) => node.x)));
const originY = snapArrangeCoordinate(Math.min(...rootNodes.map((node) => node.y)));
const updates: CanvasArrangePositionUpdate[] = [];
let cursorX = originX;
let cursorY = originY;
let rowHeight = 0;
for (const component of components) {
if (
cursorX > originX &&
cursorX + component.width > originX + CANVAS_ARRANGE_MAX_ROW_WIDTH
) {
cursorY += rowHeight + CANVAS_ARRANGE_COMPONENT_GAP;
cursorX = originX;
rowHeight = 0;
}
for (const [nodeId, position] of component.positions) {
const node = nodesById.get(nodeId);
if (!node) continue;
const x = snapArrangeCoordinate(cursorX + position.x);
const y = snapArrangeCoordinate(cursorY + position.y);
if (Math.abs(x - node.x) > 0.5 || Math.abs(y - node.y) > 0.5) {
updates.push({ id: nodeId, position: { x, y } });
}
}
cursorX += component.width + CANVAS_ARRANGE_COMPONENT_GAP;
rowHeight = Math.max(rowHeight, component.height);
}
return updates;
}
export function applyCanvasArrangeUpdates<TNode extends CanvasArrangeNode>(
nodes: TNode[],
updates: CanvasArrangePositionUpdate[]
) {
const positionsById = new Map(updates.map((update) => [update.id, update.position]));
return nodes.map((node) => {
const position = positionsById.get(node.id);
return position ? { ...node, ...position } : node;
});
}
export function getArrangeCanvasViewport({
canvasHeight,
canvasWidth,
maxZoom,
minZoom,
nodes,
}: {
canvasHeight: number;
canvasWidth: number;
maxZoom: number;
minZoom: number;
nodes: CanvasArrangeNode[];
}) {
if (nodes.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return null;
const minX = Math.min(...nodes.map((node) => node.x));
const minY = Math.min(...nodes.map((node) => node.y));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) return null;
const horizontalPadding = Math.floor(
((canvasWidth - canvasWidth / (1 + CANVAS_ARRANGE_VIEWPORT_PADDING)) * 0.5)
);
const verticalPadding = Math.floor(
((canvasHeight - canvasHeight / (1 + CANVAS_ARRANGE_VIEWPORT_PADDING)) * 0.5)
);
const zoom = Math.min(
maxZoom,
Math.max(
minZoom,
Math.min(
(canvasWidth - 2 * horizontalPadding) / width,
(canvasHeight - 2 * verticalPadding) / height
)
)
);
return {
x: CANVAS_ARRANGE_VIEWPORT_OFFSET - minX * zoom,
y: CANVAS_ARRANGE_VIEWPORT_OFFSET - minY * zoom,
zoom,
};
}