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)) };
    }),
  };
}