Spaces:
Running
Running
File size: 7,168 Bytes
b9499ab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | 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<Record<Operation, string>> = { add: "+", sub: "−", mul: "·", div: "/", pow: "^" };
const SYMBOLS: Partial<Record<Operation, string>> = { 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<PlacedEdge, "labelAt" | "labelSlope"> {
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<PlacedShape, "x" | "y">[] = 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)) };
}),
};
}
|