import dagre from "@dagrejs/dagre"; import type { Graph, GraphNode, Operation } from "./types"; const NODE_SIZE = 40; // How far a node's value and derivative sit from its centre, above and below its shape export const SLOT_OFFSET = 28; // Room kept for a node with its value and derivative const NODE_ROOM_WIDTH = 60; const NODE_ROOM_HEIGHT = 76; const CHAR_WIDTH = 7.2; // Room kept around each edge for its name const LABEL_WIDTH = 44; const LABEL_HEIGHT = 20; // Flatter than this, a label can stay centred on its edge const LEVEL_SLOPE = 0.08; // A hidden node before every input, pulling them all into the first column const SOURCE = "\0source"; // The box at the end, f export const OUTPUT = "\0output"; interface Point { x: number; y: number; } export interface PlacedShape { id: string; kind: GraphNode["kind"] | "output"; label: string; // The centre x: number; y: number; width: number; height: number; } export interface PlacedEdge { id: string; from: string; to: string; points: Point[]; // The point on the edge that its label straddles labelAt: Point; // The edge's direction there, going right: it rises (-1), falls (1) or is level (0) labelSlope: -1 | 0 | 1; } export interface GraphLayout { shapes: PlacedShape[]; edges: PlacedEdge[]; width: number; height: number; } const INFIX: Partial> = { add: "+", sub: "−", mul: "·", div: "/", pow: "^" }; const SYMBOLS: Partial> = { add: "+", sub: "−", mul: "*", div: "/", pow: "^", neg: "−", sqrt: "√" }; // What a node computes, e.g. "w1 + 2" or "sin(w1)"; an input's is its name. // Pass show to write its arguments another way, such as their numbers. export function expressionOf(node: GraphNode, show: (arg: string) => string = (arg) => arg): string { if (node.kind === "input") { return node.name; } const [a, b] = node.args.map(show); if (node.op in INFIX) { return `${a} ${INFIX[node.op]} ${b}`; } return node.op === "neg" ? `−${a}` : `${node.op}(${a})`; } // What a node's shape shows: its name, or its operator with any constant, e.g. "2 ^" function labelOf(node: GraphNode, graph: Graph): string { if (node.kind === "input") { return node.name; } const symbol = SYMBOLS[node.op] ?? node.op; const constant = (arg: string | undefined) => arg !== undefined && arg in graph.constants; if (constant(node.args[0])) { return `${node.args[0]} ${symbol}`; } return constant(node.args[1]) ? `${symbol} ${node.args[1]}` : symbol; } // A smooth line through the points export function edgePath({ points }: PlacedEdge): string { const [first, ...rest] = points; const last = rest.pop()!; let path = `M ${first.x} ${first.y}`; for (let i = 0; i < rest.length; i++) { const next = rest[i + 1] ?? last; path += ` Q ${rest[i].x} ${rest[i].y} ${(rest[i].x + next.x) / 2} ${(rest[i].y + next.y) / 2}`; } return `${path} L ${last.x} ${last.y}`; } // Where the line from a shape's centre towards a point leaves the shape function onShape(shape: PlacedShape, toward: Point): Point { const dx = toward.x - shape.x; const dy = toward.y - shape.y; const across = Math.abs(dx / (shape.width / 2)); const down = Math.abs(dy / (shape.height / 2)); const scale = 1 / (shape.kind === "op" ? Math.hypot(across, down) : Math.max(across, down)); return { x: shape.x + dx * scale, y: shape.y + dy * scale }; } // Where the drawn curve passes a dagre point, and which way it is heading function labelPlacement(points: Point[], label: Point): Pick { const distance = (point: Point) => Math.hypot(point.x - label.x, point.y - label.y); const i = points.reduce((best, point, j) => (distance(point) < distance(points[best]) ? j : best), 0); const before = points[Math.max(i - 1, 0)]; const after = points[Math.min(i + 1, points.length - 1)]; const slope = (after.y - before.y) / (after.x - before.x); return { // Edges are smoothed, so they cut the corner at each point labelAt: { x: (before.x + 6 * points[i].x + after.x) / 8, y: (before.y + 6 * points[i].y + after.y) / 8 }, labelSlope: Math.abs(slope) < LEVEL_SLOPE ? 0 : slope < 0 ? -1 : 1, }; } // Left to right in evaluation order, with the inputs in a column on the left export function layoutGraph(graph: Graph): GraphLayout { const g = new dagre.graphlib.Graph({ multigraph: true }); g.setGraph({ rankdir: "LR", nodesep: 12, ranksep: 16, edgesep: 28, marginx: 4, marginy: 4 }); g.setDefaultEdgeLabel(() => ({})); const size = (label: string) => ({ width: Math.max(NODE_SIZE, label.length * CHAR_WIDTH + 24), height: NODE_SIZE }); const unplaced: Omit[] = graph.nodes.map((node) => { const label = labelOf(node, graph); return { id: node.id, kind: node.kind, label, ...size(label) }; }); unplaced.push({ id: OUTPUT, kind: "output", label: "f", ...size("f") }); for (const { id, width } of unplaced) { g.setNode(id, { width: Math.max(width, NODE_ROOM_WIDTH), height: NODE_ROOM_HEIGHT }); } const nodeIds = new Set(graph.nodes.map((node) => node.id)); const edges: { from: string; to: string; id: string }[] = [{ from: graph.output, to: OUTPUT, id: `${graph.output}-output` }]; for (const node of graph.nodes) { if (node.kind === "op") { node.args.forEach((from, i) => { if (nodeIds.has(from)) { edges.push({ from, to: node.id, id: `${from}-${node.id}-${i}` }); } }); } } for (const edge of edges) { g.setEdge(edge.from, edge.to, { width: LABEL_WIDTH, height: LABEL_HEIGHT, labelpos: "c" }, edge.id); } g.setNode(SOURCE, { width: 0, height: 0 }); for (const name of graph.nodes.filter((node) => node.kind === "input")) { g.setEdge(SOURCE, name.id, { weight: 100 }, SOURCE); } dagre.layout(g); // dagre doesn't keep the inputs' order, so mirror the drawing if they came out reversed const inputY = graph.nodes.filter((node) => node.kind === "input").map((node) => g.node(node.id).y); const mirrored = inputY[0] > inputY[inputY.length - 1]; const { height = 0 } = g.graph(); // Measure without the hidden node's column const left = Math.min(...unplaced.map(({ id }) => g.node(id).x - g.node(id).width / 2)) - 4; const place = ({ x, y }: Point): Point => ({ x: x - left, y: mirrored ? height - y : y }); const shapes: PlacedShape[] = unplaced.map((shape) => ({ ...shape, ...place(g.node(shape.id)) })); const shapeOf = new Map(shapes.map((shape) => [shape.id, shape])); const width = Math.max(...unplaced.map(({ id }) => g.node(id).x + g.node(id).width / 2)) - left + 4; return { shapes, width, height, edges: edges.map(({ from, to, id }) => { const edge = g.edge(from, to, id); const points: Point[] = edge.points.map(place); // dagre ends edges on the room kept for a node, not on its shape points[0] = onShape(shapeOf.get(from)!, points[1]); points[points.length - 1] = onShape(shapeOf.get(to)!, points[points.length - 2]); return { id, from, to, points, ...labelPlacement(points, place(edge)) }; }), }; }