layout.ts

examples/fxedit/layout.ts · raw · open the example · all examples · site home

Exports: PortBoxNodeBoxLayoutOptionsLayoutResultautoLayout

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
// Auto-layout with ELK's layered algorithm (elkjs, EPL-2.0), run in a worker.
// elk-api.js is UMD, not a module: index.html loads it from node_modules as
// a classic script (window.ELK), and its worker is the elk-worker.min.js
// beside it (a single-file build inlines both and sets window.ELK_WORKER_URL).
// Ports sit where the DOM put them (FIXED_POS), so wires leave and enter
// where they are drawn and ELK orders nodes to cut crossings.
import type ElkApi from "elkjs/lib/elk-api.js";
import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api";
import { GROUP_INPUT, GROUP_OUTPUT, splitPort, type GraphLevel } from "./fx/index.js";

const ELK = (window as unknown as { ELK: typeof ElkApi }).ELK;
const elkScript = document.querySelector<HTMLScriptElement>('script[src$="elk-api.js"]');
const ELK_WORKER = (window as unknown as { ELK_WORKER_URL?: string }).ELK_WORKER_URL ?? (elkScript ? elkScript.src.replace(/elk-api\.js$/, "elk-worker.min.js") : "");

export interface PortBox {
  name: string;
  side: "in" | "out";
  // Center, relative to the node's top left
  x: number;
  y: number;
}

export interface NodeBox {
  width: number;
  height: number;
  ports: PortBox[];
}

export interface LayoutOptions {
  layerSpacing: number;
  nodeSpacing: number;
}

let elk: InstanceType<typeof ElkApi> | undefined;

export interface LayoutResult {
  // Top-left corner of each node
  positions: Map<string, [number, number]>;
  // ELK's right-angle route of each link (by its input "node.port"): start,
  // bends, end; it keeps clear of nodes, including wires that run back to a
  // wrapped row
  routes: Map<string, [number, number][]>;
}

// Long chains make wide strips, so besides one row of layers this tries
// ELK's graph wrapping (rows of layers stacked to the view's shape) and keeps
// the layout that fits `view` at the largest zoom, less a penalty for wires
// that cross or run backward.
export async function autoLayout(graph: GraphLevel, boxes: Map<string, NodeBox>, { layerSpacing, nodeSpacing }: LayoutOptions, view: { width: number; height: number }): Promise<LayoutResult> {
  elk ??= new ELK({ workerUrl: ELK_WORKER });
  const portId = (node: string, side: string, name: string) => `${node}|${side}|${name}`;
  const ports = new Set<string>();
  const children: ElkNode[] = [...boxes].map(([id, box]) => ({
    id,
    width: box.width,
    height: box.height,
    // A group's inputs stay at the left edge and its outputs at the right
    layoutOptions: {
      "elk.portConstraints": "FIXED_POS",
      ...(graph.nodes[id]?.type === GROUP_INPUT ? { "elk.layered.layering.layerConstraint": "FIRST" } : {}),
      ...(graph.nodes[id]?.type === GROUP_OUTPUT ? { "elk.layered.layering.layerConstraint": "LAST" } : {}),
    },
    ports: box.ports.map((port) => {
      const pid = portId(id, port.side, port.name);
      ports.add(pid);
      return { id: pid, x: port.x - 1, y: port.y - 1, width: 2, height: 2, layoutOptions: { "elk.port.side": port.side === "in" ? "WEST" : "EAST" } };
    }),
  }));
  const edges = graph.links.flatMap((link, i) => {
    const [fromNode, fromPort] = splitPort(link.from);
    const [toNode, toPort] = splitPort(link.to);
    const source = portId(fromNode, "out", fromPort);
    const target = portId(toNode, "in", toPort);
    return ports.has(source) && ports.has(target) ? [{ id: `e${i}`, sources: [source], targets: [target] }] : [];
  });

  const base: Record<string, string> = {
    "elk.algorithm": "layered",
    "elk.direction": "RIGHT",
    "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
    "elk.spacing.nodeNode": String(nodeSpacing),
    "elk.layered.spacing.edgeNodeBetweenLayers": String(Math.round(nodeSpacing * 0.8)),
    "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
    "elk.layered.crossingMinimization.thoroughness": "30",
    "elk.edgeRouting": "ORTHOGONAL",
    "elk.separateConnectedComponents": "true",
    "elk.spacing.componentComponent": String(layerSpacing),
    "elk.padding": "[top=0,left=0,bottom=0,right=0]",
  };
  // The JSON's node order (stable, often how the author thinks) or free
  // reordering (fewer crossings)
  const ordered = { ...base, "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES" };
  const aspect = Math.max(0.3, view.width / Math.max(1, view.height));
  const wrapped = (options: Record<string, string>, strategy: string) => ({
    ...options,
    "elk.layered.wrapping.strategy": strategy,
    "elk.aspectRatio": aspect.toFixed(2),
    "elk.layered.wrapping.additionalEdgeSpacing": String(nodeSpacing),
    "elk.layered.wrapping.multiEdge.improveCuts": "true",
    "elk.layered.wrapping.multiEdge.improveWrappedEdges": "true",
  });
  const candidates = [base, ordered, wrapped(base, "SINGLE_EDGE"), wrapped(base, "MULTI_EDGE"), wrapped(ordered, "MULTI_EDGE")];
  const results = await Promise.all(candidates.map((options) => elk!.layout({ id: "root", layoutOptions: options, children, edges } as ElkNode).catch(() => undefined)));

  const inputOf = new Map(edges.map((edge, i) => [edge.id, graph.links[Number(edge.id.slice(1))]?.to ?? String(i)]));
  // Inside a group, inputs-left-to-outputs-right reads best: one row
  // whenever it fits at the smallest zoom a fit allows
  const zoomOf = (result: ElkNode | undefined) => (result ? Math.min(view.width / ((result.width ?? 1) + 80), view.height / ((result.height ?? 1) + 80)) : 0);
  const boundary = Object.values(graph.nodes).some((node) => node.type === GROUP_INPUT || node.type === GROUP_OUTPUT);
  const oneRow = boundary && Math.max(zoomOf(results[0]), zoomOf(results[1])) >= 0.36;
  let best: LayoutResult | undefined;
  let bestScore = -Infinity;
  for (const [i, result] of results.entries()) {
    if (!result?.children || (oneRow && i >= 2)) {
      continue;
    }
    const positions = new Map(result.children.map((child) => [child.id, [child.x ?? 0, child.y ?? 0] as [number, number]]));
    const routes = new Map<string, [number, number][]>();
    for (const edge of (result.edges ?? []) as ElkExtendedEdge[]) {
      const section = edge.sections?.[0];
      const input = inputOf.get(edge.id);
      if (section && input) {
        routes.set(input, [section.startPoint, ...(section.bendPoints ?? []), section.endPoint].map((p) => [p.x, p.y]));
      }
    }
    // Past this zoom a layout is readable enough; below it, size counts
    const zoom = Math.min(0.8, view.width / ((result.width ?? 1) + 80), view.height / ((result.height ?? 1) + 80));
    const { crossings, backward } = tangle(graph, boxes, positions);
    const score = zoom / (1 + 0.03 * crossings + 0.03 * backward);
    if (score > bestScore) {
      best = { positions, routes };
      bestScore = score;
    }
  }
  if (!best) {
    throw new Error("ELK found no layout");
  }
  return best;
}

// How hard a layout's wires are to follow, with wires as straight port-to-
// port segments: pairs that cross and wires running right to left
function tangle(graph: GraphLevel, boxes: Map<string, NodeBox>, positions: Map<string, [number, number]>) {
  const port = (node: string, side: "in" | "out", name: string): [number, number] | undefined => {
    const p = positions.get(node);
    const offset = boxes.get(node)?.ports.find((q) => q.side === side && q.name === name);
    return p && offset ? [p[0] + offset.x, p[1] + offset.y] : undefined;
  };
  const wires = graph.links.flatMap((link) => {
    const [fromNode, fromPort] = splitPort(link.from);
    const [toNode, toPort] = splitPort(link.to);
    const a = port(fromNode, "out", fromPort);
    const b = port(toNode, "in", toPort);
    return a && b ? [{ a, b }] : [];
  });
  const cross = (p: number[], q: number[], r: number[], s: number[]) => {
    const d = (a: number[], b: number[], c: number[]) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
    return d(p, q, r) * d(p, q, s) < 0 && d(r, s, p) * d(r, s, q) < 0;
  };
  let crossings = 0;
  for (let i = 0; i < wires.length; i += 1) {
    for (let j = i + 1; j < wires.length; j += 1) {
      if (cross(wires[i].a, wires[i].b, wires[j].a, wires[j].b)) {
        crossings += 1;
      }
    }
  }
  const backward = wires.filter((wire) => wire.b[0] < wire.a[0]).length;
  return { crossings, backward };
}