// Where the flow editor's canvas puts each node: the tree runs left to // right, one column per level, a leaf per row, and a parent sits at the // middle of its children. Computed from the flow on every edit, so nothing // about the layout is stored. import type { Flow } from "./FlowTypes.js"; export const NODE_WIDTH = 190; export const NODE_HEIGHT = 46; const GAP_X = 56; const GAP_Y = 14; const PAD = 12; export interface PlacedNode { id: string; depth: number; // Top-left corner. x: number; y: number; } export interface PlacedEdge { from: string; to: string; // Right-middle of the parent, left-middle of the child. x1: number; y1: number; x2: number; y2: number; } export interface Layout { nodes: PlacedNode[]; edges: PlacedEdge[]; width: number; height: number; } // The node whose box holds the point (`x`, `y`) in canvas coordinates, if any. export function nodeAt( layout: Layout, x: number, y: number, ): string | undefined { return layout.nodes.find( (n) => x >= n.x && x <= n.x + NODE_WIDTH && y >= n.y && y <= n.y + NODE_HEIGHT, )?.id; } // The node next to `id` in direction `dir` on the canvas: left is its parent, // right its first child, up and down the nearest box above or below it in the // same column. Undefined at an edge, so a key press there does nothing. export function neighbour( layout: Layout, flow: Flow, id: string, dir: "up" | "down" | "left" | "right", ): string | undefined { const me = layout.nodes.find((n) => n.id === id); if (!me) return undefined; if (dir === "left") return flow.edges.find((e) => e.to === id)?.from; if (dir === "right") return flow.edges.find((e) => e.from === id)?.to; const column = layout.nodes .filter((n) => n.depth === me.depth && n.id !== id) .filter((n) => (dir === "up" ? n.y < me.y : n.y > me.y)) .sort((a, b) => (dir === "up" ? b.y - a.y : a.y - b.y)); return column[0]?.id; } export function layoutFlow(flow: Flow): Layout { const children = new Map(); for (const e of flow.edges) { children.set(e.from, [...(children.get(e.from) ?? []), e.to]); } const known = new Set(flow.nodes.map((n) => n.id)); const placed = new Map(); let row = 0; // Depth first, so a leaf takes the next row and a parent the middle of its // first and last child. `placed` also stops a cycle from looping. const place = (id: string, depth: number): number => { const kids = (children.get(id) ?? []).filter( (k) => known.has(k) && !placed.has(k), ); const at = (r: number): number => PAD + r * (NODE_HEIGHT + GAP_Y); const x = PAD + depth * (NODE_WIDTH + GAP_X); // Reserve the spot first so a node cannot be reached twice. placed.set(id, { id, depth, x, y: 0 }); // A node with two parents (a draft the save would refuse) is placed under // the first that reaches it. const ys: number[] = []; for (const k of kids) if (!placed.has(k)) ys.push(place(k, depth + 1)); const y = ys.length === 0 ? at(row++) : (ys[0] + ys[ys.length - 1]) / 2; placed.set(id, { id, depth, x, y }); return y; }; const root = flow.nodes.find((n) => n.kind === "source"); if (root) place(root.id, 0); // A draft being rewired can hold a node nothing reaches yet: show it below // rather than lose it, until it is attached. for (const n of flow.nodes) { if (!placed.has(n.id)) place(n.id, 0); } const nodes = flow.nodes.map((n) => placed.get(n.id) as PlacedNode); const edges = flow.edges .filter((e) => placed.has(e.from) && placed.has(e.to)) .map((e) => { const a = placed.get(e.from) as PlacedNode; const b = placed.get(e.to) as PlacedNode; return { from: e.from, to: e.to, x1: a.x + NODE_WIDTH, y1: a.y + NODE_HEIGHT / 2, x2: b.x, y2: b.y + NODE_HEIGHT / 2, }; }); return { nodes, edges, width: Math.max(0, ...nodes.map((n) => n.x + NODE_WIDTH)) + PAD, height: Math.max(0, ...nodes.map((n) => n.y + NODE_HEIGHT)) + PAD, }; }