@rse/gradia 1.1.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { FS_GROUP, FS_TYPE, textWidth } from "./gradia-api-render-base.js";
7
+ import { parentOf, containerTypeOf, containerHead, typeOf, defaultStyleOf } from "./gradia-api-render-node.js";
8
+ import { simplifyPoly } from "./gradia-api-render-edge.js";
9
+ /* determine the containment tree of the graph (in order of node
10
+ appearance) and validate it: every "parent" has to be a declared
11
+ node, no node may be nested into itself, no edge may connect a
12
+ container with one of its own members, and every "container"
13
+ annotation has to name a valid diagram type */
14
+ export const containmentOf = (graph, types) => {
15
+ const parentOfId = new Map();
16
+ const childrenOf = new Map();
17
+ for (const node of graph.nodes.values()) {
18
+ const type = containerTypeOf(node);
19
+ if (type !== undefined && !types.includes(type))
20
+ throw new Error(`invalid container layout type "${type}" of node "${node.id}"`);
21
+ const parent = parentOf(node);
22
+ if (parent === undefined)
23
+ continue;
24
+ if (!graph.nodes.has(parent))
25
+ throw new Error(`container "${parent}" of node "${node.id}" is not a declared node`);
26
+ parentOfId.set(node.id, parent);
27
+ const children = childrenOf.get(parent);
28
+ if (children === undefined)
29
+ childrenOf.set(parent, [node]);
30
+ else
31
+ children.push(node);
32
+ }
33
+ for (const id of parentOfId.keys()) {
34
+ const seen = new Set([id]);
35
+ for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p)) {
36
+ if (seen.has(p))
37
+ throw new Error(`node "${id}" cannot be nested into itself (containment cycle)`);
38
+ seen.add(p);
39
+ }
40
+ }
41
+ const isAncestor = (a, id) => {
42
+ for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p))
43
+ if (p === a)
44
+ return true;
45
+ return false;
46
+ };
47
+ for (const edge of graph.edges)
48
+ if (isAncestor(edge.source, edge.target) || isAncestor(edge.target, edge.source))
49
+ throw new Error(`edge "${edge.source}" --> "${edge.target}" ` +
50
+ "connects a container with one of its own members");
51
+ return { parentOf: parentOfId, childrenOf };
52
+ };
53
+ /* the outermost container of a node (the node itself if not nested) */
54
+ export const rootOf = (containment, id) => {
55
+ let root = id;
56
+ for (let p = containment.parentOf.get(root); p !== undefined; p = containment.parentOf.get(root))
57
+ root = p;
58
+ return root;
59
+ };
60
+ /* determine the bounding box of a laid out graph (its nodes, edges,
61
+ container boxes, and the additionally given polylines) */
62
+ export const boundsOf = (layout, extra = []) => {
63
+ const { nodes, cx, cy, boxW, boxH, polys } = layout;
64
+ let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
65
+ for (const node of nodes) {
66
+ minX = Math.min(minX, cx(node.id) - boxW.get(node.id) / 2);
67
+ minY = Math.min(minY, cy(node.id) - boxH.get(node.id) / 2);
68
+ maxX = Math.max(maxX, cx(node.id) + boxW.get(node.id) / 2);
69
+ maxY = Math.max(maxY, cy(node.id) + boxH.get(node.id) / 2);
70
+ }
71
+ for (const poly of [...polys, ...extra]) {
72
+ for (const [px, py] of poly) {
73
+ minX = Math.min(minX, px);
74
+ minY = Math.min(minY, py);
75
+ maxX = Math.max(maxX, px);
76
+ maxY = Math.max(maxY, py);
77
+ }
78
+ }
79
+ for (const c of layout.containers ?? []) {
80
+ minX = Math.min(minX, c.x);
81
+ minY = Math.min(minY, c.y);
82
+ maxX = Math.max(maxX, c.x + c.w);
83
+ maxY = Math.max(maxY, c.y + c.h);
84
+ }
85
+ if (!Number.isFinite(minX))
86
+ [minX, minY, maxX, maxY] = [0, 0, 0, 0];
87
+ return { minX, minY, maxX, maxY };
88
+ };
89
+ /* the id of the gate node carrying the given edge across a container
90
+ boundary (the control character keeps it apart from all user ids) */
91
+ const gateId = (edge, side) => `\u0000gate:${edge}:${side}`;
92
+ /* lay out a graph model with its container nodes: each container is
93
+ laid out as a containment level of its own (innermost first, with the
94
+ diagram type of the enclosing level unless annotated otherwise), and
95
+ then takes part in the layout of its enclosing level as a placeholder
96
+ node of the size of its box. An edge crossing a container boundary
97
+ is lifted to the level where both its endpoints (or the containers
98
+ holding them) are members, while inside every crossed container (of
99
+ a gated diagram type) it continues from a gate node on the boundary
100
+ to the inner node. The partial routes are finally stitched into
101
+ the complete route of the edge */
102
+ export const renderContained = async (graph, containment, type, types, config) => {
103
+ const nodes = Array.from(graph.nodes.values());
104
+ const isContainer = (node) => containment.childrenOf.has(node.id) || containerTypeOf(node) !== undefined;
105
+ /* short-cut the plain case of a graph without any container */
106
+ if (!nodes.some(isContainer))
107
+ return types[type].render(graph, config);
108
+ const pad = config["container-box-padding"];
109
+ /* reject the node ids which could collide with the gate node ids */
110
+ for (const node of nodes)
111
+ if (node.id.includes("\u0000"))
112
+ throw new Error(`node id ${JSON.stringify(node.id)} contains a reserved control character`);
113
+ /* the box size of a container placeholder: its content plus
114
+ the padding and the head, at least as wide as its tag */
115
+ const sizeOf = (member, b) => {
116
+ const tag = Math.max(textWidth(member.name, FS_GROUP), textWidth(typeOf(member) ?? "", FS_TYPE));
117
+ return {
118
+ w: Math.max(b.maxX - b.minX, tag) + pad * 2,
119
+ h: b.maxY - b.minY + pad * 2 + containerHead(member)
120
+ };
121
+ };
122
+ /* compose a laid out containment level with its inner levels: the
123
+ inner layouts are shifted into their container boxes, the edges
124
+ of the level are stitched together with the partial routes
125
+ inside the containers they leave or enter, and the placeholders
126
+ and gates are dropped (the container boxes are rendered in
127
+ their place) */
128
+ const composeLevel = (layout, inner, levelEdges, origin, gates, type) => {
129
+ /* shift every inner layout into its container box (the parts
130
+ are the level layout itself and the shifted inner layouts,
131
+ with every node id dispatching to the part it belongs to) */
132
+ const parts = [{ layout, dx: 0, dy: 0 }];
133
+ const partOf = new Map();
134
+ for (const node of layout.nodes)
135
+ partOf.set(node.id, 0);
136
+ const boxes = [];
137
+ const innerBoxes = [];
138
+ for (const [id, level] of inner) {
139
+ const member = graph.nodes.get(id);
140
+ if (!layout.nodes.some((node) => node.id === id))
141
+ throw new Error(`container "${id}" cannot be placed twice by diagram type "${type}"`);
142
+ const { w, h } = sizeOf(member, level.bounds);
143
+ const cx = layout.cx(id);
144
+ const cy = layout.cy(id);
145
+ const k = parts.push({
146
+ layout: level.layout,
147
+ dx: cx - w / 2 + pad - level.bounds.minX,
148
+ dy: cy - h / 2 + containerHead(member) + pad - level.bounds.minY
149
+ }) - 1;
150
+ for (const node of level.layout.nodes)
151
+ partOf.set(node.id, k);
152
+ partOf.set(id, k);
153
+ boxes.push({
154
+ node: member,
155
+ x: cx - layout.boxW.get(id) / 2,
156
+ y: cy - layout.boxH.get(id) / 2,
157
+ w: layout.boxW.get(id),
158
+ h: layout.boxH.get(id)
159
+ });
160
+ innerBoxes.push(...(level.layout.containers ?? []).map((c) => ({ ...c, x: c.x + parts[k].dx, y: c.y + parts[k].dy })));
161
+ }
162
+ const shift = (poly, k) => poly.map(([px, py]) => [px + parts[k].dx, py + parts[k].dy]);
163
+ /* collect the edges completed within the inner levels, then
164
+ the edges of this level, each stitched together with the
165
+ partial routes inside the containers it leaves or enters
166
+ (an edge crossing the boundary of this level stays partial) */
167
+ const edges = [];
168
+ const polys = [];
169
+ const partial = new Map();
170
+ parts.forEach((part, k) => {
171
+ if (k === 0)
172
+ return;
173
+ part.layout.edges.forEach((edge, ei) => {
174
+ edges.push(edge);
175
+ polys.push(shift(part.layout.polys[ei], k));
176
+ });
177
+ });
178
+ levelEdges.forEach((edge, li) => {
179
+ const i = origin[li];
180
+ const head = inner.get(edge.source)?.partial.get(i);
181
+ const tail = inner.get(edge.target)?.partial.get(i);
182
+ const poly = simplifyPoly([
183
+ ...(head !== undefined ? shift(head, partOf.get(edge.source)) : []),
184
+ ...layout.polys[li],
185
+ ...(tail !== undefined ? shift(tail, partOf.get(edge.target)) : [])
186
+ ]);
187
+ if (gates.has(edge.source) || gates.has(edge.target))
188
+ partial.set(i, poly);
189
+ else {
190
+ edges.push(graph.edges[i]);
191
+ polys.push(poly);
192
+ }
193
+ });
194
+ /* compose the level (the placeholders and gates are dropped,
195
+ as the container boxes are rendered in their place) */
196
+ const merge = (pick) => new Map(parts.flatMap((part) => Array.from(pick(part.layout).entries())));
197
+ const composed = {
198
+ nodes: [
199
+ ...layout.nodes.filter((node) => !inner.has(node.id) && !gates.has(node.id)),
200
+ ...parts.slice(1).flatMap((part) => part.layout.nodes)
201
+ ],
202
+ edges,
203
+ cx: (id) => parts[partOf.get(id)].layout.cx(id) + parts[partOf.get(id)].dx,
204
+ cy: (id) => parts[partOf.get(id)].layout.cy(id) + parts[partOf.get(id)].dy,
205
+ boxW: merge((layout) => layout.boxW),
206
+ boxH: merge((layout) => layout.boxH),
207
+ contentH: merge((layout) => layout.contentH),
208
+ polys,
209
+ styleOf: (node) => (parts[partOf.get(node.id)].layout.styleOf ?? defaultStyleOf)(node),
210
+ containers: [...boxes, ...innerBoxes]
211
+ };
212
+ return { layout: composed, bounds: boundsOf(composed, Array.from(partial.values())), partial };
213
+ };
214
+ /* lay out a single containment level */
215
+ const layoutLevel = async (container, type) => {
216
+ const members = container === null ?
217
+ nodes.filter((node) => parentOf(node) === undefined) :
218
+ containment.childrenOf.get(container.id) ?? [];
219
+ const memberIds = new Set(members.map((member) => member.id));
220
+ /* the member of this level a node of its subtree belongs to */
221
+ const topOf = (id) => {
222
+ for (let cur = id; cur !== undefined; cur = containment.parentOf.get(cur))
223
+ if (memberIds.has(cur))
224
+ return cur;
225
+ return undefined;
226
+ };
227
+ /* lay out the member containers first */
228
+ const inner = new Map();
229
+ for (const member of members)
230
+ if (isContainer(member))
231
+ inner.set(member.id, await layoutLevel(member, containerTypeOf(member) ?? type));
232
+ /* the vertical offset (from the placeholder center) of the
233
+ port an edge crossing the boundary of a member container
234
+ attaches at: the position of its inner gate */
235
+ const portOffset = (member, y) => {
236
+ const { bounds } = inner.get(member.id);
237
+ return (y - bounds.minY) + containerHead(member) + pad - sizeOf(member, bounds).h / 2;
238
+ };
239
+ /* derive the edges of this level: the edges lifted to it (both
240
+ endpoints in the subtree, but not both inside the same member
241
+ container), and, for a gated diagram type, the edges crossing
242
+ its own boundary, continued from a gate node. The name of an
243
+ edge is placed at its lifted level, its arity at the level
244
+ holding its arrow head */
245
+ const gates = new Map();
246
+ const levelNodes = new Map(members.map((member) => [member.id, member]));
247
+ const levelEdges = [];
248
+ const origin = [];
249
+ const fixedPort = new Map();
250
+ const continues = (id, edge) => inner.get(id)?.partial.has(edge) ?? false;
251
+ graph.edges.forEach((edge, i) => {
252
+ const ts = topOf(edge.source);
253
+ const tt = topOf(edge.target);
254
+ if (ts === undefined && tt === undefined)
255
+ return;
256
+ if (ts !== undefined && tt !== undefined && ts === tt && inner.has(ts) && edge.source !== ts)
257
+ return;
258
+ let source;
259
+ let target;
260
+ if (ts === undefined || tt === undefined) {
261
+ if (!types[type].gated)
262
+ return;
263
+ const gate = gateId(i, ts === undefined ? "in" : "out");
264
+ gates.set(gate, ts === undefined ? "in" : "out");
265
+ levelNodes.set(gate, { id: gate, name: "", attrs: [] });
266
+ source = ts ?? gate;
267
+ target = tt ?? gate;
268
+ }
269
+ else {
270
+ source = ts;
271
+ target = tt;
272
+ }
273
+ const li = levelEdges.length;
274
+ levelEdges.push({
275
+ source,
276
+ target,
277
+ name: ts !== undefined && tt !== undefined ? edge.name : undefined,
278
+ arity: tt !== undefined && !continues(tt, i) ? edge.arity : undefined
279
+ });
280
+ origin.push(i);
281
+ if (continues(source, i))
282
+ fixedPort.set(`${li}:s`, portOffset(levelNodes.get(source), inner.get(source).partial.get(i).at(-1)[1]));
283
+ if (continues(target, i))
284
+ fixedPort.set(`${li}:t`, portOffset(levelNodes.get(target), inner.get(target).partial.get(i)[0][1]));
285
+ });
286
+ /* lay out the level with the member containers as placeholders */
287
+ const fixedSize = new Map();
288
+ for (const [id, level] of inner)
289
+ fixedSize.set(id, sizeOf(levelNodes.get(id), level.bounds));
290
+ for (const gate of gates.keys())
291
+ fixedSize.set(gate, { w: 0, h: 0 });
292
+ const layout = await types[type].render({ nodes: levelNodes, edges: levelEdges }, config, {
293
+ fixedSize,
294
+ fixedPort: (edge, role) => fixedPort.get(`${edge}:${role}`),
295
+ gates
296
+ });
297
+ return composeLevel(layout, inner, levelEdges, origin, gates, type);
298
+ };
299
+ return (await layoutLevel(null, type)).layout;
300
+ };
@@ -5,7 +5,7 @@ export type Side = "e" | "w" | "n";
5
5
  export declare const assignPorts: (edges: Edge[], sides: {
6
6
  s: Side;
7
7
  t: Side;
8
- }[], cx: (id: string) => number, cy: (id: string) => number, boxW: Map<string, number>, boxH: Map<string, number>, portGap: number, approachY?: (edge: number) => number | undefined) => Map<string, {
8
+ }[], cx: (id: string) => number, cy: (id: string) => number, boxW: Map<string, number>, boxH: Map<string, number>, portGap: number, approachY?: (edge: number) => number | undefined, fixedY?: (edge: number, role: "s" | "t") => number | undefined) => Map<string, {
9
9
  x: number;
10
10
  y: number;
11
11
  }>;
@@ -28,8 +28,12 @@ export const simplifyPoly = (pts) => {
28
28
  by the vertical position the edge approaches from (the approachY
29
29
  override where given, the opposite endpoint center otherwise) and
30
30
  separated by at most portGap (less, if the node box is too small
31
- to hold them all) */
32
- export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY) => {
31
+ to hold them all). A port with a fixed vertical offset from the
32
+ box center (fixedY, the edge ends attaching to a container box at
33
+ the position of the inner gate node) is placed exactly there and
34
+ the distributed ports of the same side are dealt into the gaps
35
+ around it */
36
+ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY, fixedY) => {
33
37
  /* group the edge endpoints by the node side they attach to */
34
38
  const portGroups = new Map();
35
39
  const addPort = (key, edge, role) => {
@@ -63,15 +67,59 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
63
67
  });
64
68
  continue;
65
69
  }
70
+ const px = cx(id) + (side === "e" ? +1 : -1) * boxW.get(id) / 2;
71
+ /* place the fixed ports at their given offsets */
72
+ const fixedOf = (p) => fixedY?.(p.edge, p.role);
73
+ const taken = [];
74
+ for (const p of group.filter((p) => fixedOf(p) !== undefined)) {
75
+ const py = cy(id) + fixedOf(p);
76
+ portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
77
+ taken.push(py);
78
+ }
79
+ taken.sort((a, b) => a - b);
80
+ /* spread the remaining ports, ordered by their approach and
81
+ at the spacing leaving room for the fixed ports, too (with
82
+ no fixed port, simply centered on the box) */
83
+ const free = group.filter((p) => fixedOf(p) === undefined);
66
84
  const otherOf = (p) => p.role === "s" ? edges[p.edge].target : edges[p.edge].source;
67
85
  const keyOf = (p) => approachY?.(p.edge) ?? cy(otherOf(p));
68
- group.sort((a, b) => (keyOf(a) - keyOf(b)) || (a.edge - b.edge));
69
- const spacing = Math.min(portGap, (boxH.get(id) - PORT_PAD) / group.length);
70
- group.forEach((p, idx) => {
71
- portPos.set(`${p.edge}:${p.role}`, {
72
- x: cx(id) + (side === "e" ? +1 : -1) * boxW.get(id) / 2,
73
- y: cy(id) + (idx - (group.length - 1) / 2) * spacing
74
- });
86
+ free.sort((a, b) => (keyOf(a) - keyOf(b)) || (a.edge - b.edge));
87
+ const n = free.length + taken.length;
88
+ const spacing = Math.min(portGap, (boxH.get(id) - PORT_PAD) / n);
89
+ const place = (p, py) => portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
90
+ if (taken.length === 0) {
91
+ free.forEach((p, idx) => place(p, cy(id) + (idx - (n - 1) / 2) * spacing));
92
+ continue;
93
+ }
94
+ /* deal the free ports into the gaps between the fixed ports
95
+ (each into the gap its approach falls into, overflowing into
96
+ the neighboring gaps once a gap holds as many ports as fit
97
+ into it at the spacing) and place the ports of every gap at
98
+ their slots of the even distribution of all ports, shifted
99
+ into the gap as far as needed (and squeezed if not fitting) */
100
+ const lo = cy(id) - (boxH.get(id) - PORT_PAD) / 2 + spacing / 2;
101
+ const hi = cy(id) + (boxH.get(id) - PORT_PAD) / 2 - spacing / 2;
102
+ const walls = [lo - spacing, ...taken, hi + spacing];
103
+ const gapOf = (p) => taken.filter((ty) => ty < keyOf(p)).length;
104
+ const gaps = walls.slice(1).map((wall, j) => ({
105
+ lo: Math.max(lo, walls[j] + spacing),
106
+ hi: Math.min(hi, wall - spacing),
107
+ ports: free.filter((p) => gapOf(p) === j)
108
+ }));
109
+ const capOf = (gap) => gap.hi < gap.lo ? 0 : Math.floor((gap.hi - gap.lo) / spacing) + 1;
110
+ for (let j = 0; j < gaps.length - 1; j++)
111
+ while (gaps[j].ports.length > capOf(gaps[j]))
112
+ gaps[j + 1].ports.unshift(gaps[j].ports.pop());
113
+ for (let j = gaps.length - 1; j > 0; j--)
114
+ while (gaps[j].ports.length > capOf(gaps[j]))
115
+ gaps[j - 1].ports.push(gaps[j].ports.shift());
116
+ let k = 0;
117
+ gaps.forEach((gap, j) => {
118
+ const r = gap.ports.length;
119
+ const step = r > 1 ? Math.min(spacing, Math.max(gap.hi - gap.lo, 0) / (r - 1)) : spacing;
120
+ const first = Math.min(Math.max(cy(id) + (k + j - (n - 1) / 2) * spacing, gap.lo), gap.hi - (r - 1) * step);
121
+ gap.ports.forEach((p, i) => place(p, first + i * step));
122
+ k += r;
75
123
  });
76
124
  }
77
125
  return portPos;
@@ -79,6 +127,12 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
79
127
  /* the minimum cross-axis distance between the track-following segments
80
128
  of two edges sharing a track, keeping their rounded corners apart */
81
129
  const TRACK_CLEARANCE = 24;
130
+ /* the cross-axis positions of the stubs of a track user reaching the
131
+ near side of the channel/gutter (its entering segment, or its
132
+ exiting one if mirrored, or both if hairpin, or none if mirrored
133
+ hairpin) and the far side */
134
+ const nears = (u) => u.hairpin ? (u.mirror ? [] : [u.posIn, u.posOut]) : [u.mirror ? u.posOut : u.posIn];
135
+ const fars = (u) => u.hairpin ? (u.mirror ? [u.posIn, u.posOut] : []) : [u.mirror ? u.posIn : u.posOut];
82
136
  /* the number of crossings caused by placing user a on a nearer track
83
137
  than user b: the far-side stubs of a (its exiting segment, or its
84
138
  entering one if mirrored) cross the track-following segment of b,
@@ -86,26 +140,33 @@ const TRACK_CLEARANCE = 24;
86
140
  the end of a segment counts too, as it would overlap a corner) */
87
141
  const trackCrossings = (a, b) => {
88
142
  const within = (u, pos) => pos >= Math.min(u.posIn, u.posOut) && pos <= Math.max(u.posIn, u.posOut);
89
- const nears = (u) => u.hairpin ? [u.posIn, u.posOut] : [u.mirror ? u.posOut : u.posIn];
90
- const fars = (u) => u.hairpin ? [] : [u.mirror ? u.posIn : u.posOut];
91
143
  return fars(a).filter((pos) => within(b, pos)).length +
92
144
  nears(b).filter((pos) => within(a, pos)).length;
93
145
  };
146
+ /* the number of collinear overlaps caused by placing user a on a
147
+ nearer track than user b: a far-side stub of a and a near-side stub
148
+ of b at the very same cross-axis position run on top of each other
149
+ between the two tracks (unlike a crossing, which is still readable
150
+ through its hop, an overlap merges the two edges visually) */
151
+ const trackOverlaps = (a, b) => fars(a).filter((pos) => nears(b).includes(pos)).length;
94
152
  /* the maximum number of users for which the crossing-minimal track
95
153
  order is determined exactly (the effort doubles with every user) */
96
154
  const TRACK_EXACT_MAX = 14;
97
155
  /* reorder the seeded track users to the order causing the fewest
98
- crossings, with ties broken toward the seed order: for small user
99
- sets exactly, by a dynamic programming over the user subsets (the
100
- linear ordering problem), for larger ones by a local search moving
101
- single users while this reduces the crossings */
156
+ overlaps and then the fewest crossings, with ties broken toward the
157
+ seed order: for small user sets exactly, by a dynamic programming
158
+ over the user subsets (the linear ordering problem), for larger
159
+ ones by a local search moving blocks of adjacent users while this
160
+ reduces the overlaps/crossings */
102
161
  const minimizeCrossings = (seed) => {
103
162
  const n = seed.length;
104
163
  if (n < 2)
105
164
  return seed;
106
- /* the weighted cost of placing seed user i before seed user j:
107
- the crossings dominate, a seed order inversion breaks ties */
108
- const cost = seed.map((a, i) => seed.map((b, j) => trackCrossings(a, b) * n * n + (i > j ? 1 : 0)));
165
+ /* the weighted cost of placing seed user i before seed user j: the
166
+ overlaps dominate (one outweighs all crossings possible among the
167
+ users, at most three per pair), then the crossings, and finally
168
+ a seed order inversion breaks ties */
169
+ const cost = seed.map((a, i) => seed.map((b, j) => (trackOverlaps(a, b) * 2 * n * n + trackCrossings(a, b)) * n * n + (i > j ? 1 : 0)));
109
170
  let order;
110
171
  if (n <= TRACK_EXACT_MAX) {
111
172
  const best = new Float64Array(1 << n).fill(Infinity);
@@ -131,28 +192,36 @@ const minimizeCrossings = (seed) => {
131
192
  order.unshift(last[s]);
132
193
  }
133
194
  else {
134
- const totalOf = (list) => {
135
- let total = 0;
136
- for (let i = 0; i < list.length; i++)
137
- for (let j = i + 1; j < list.length; j++)
138
- total += cost[list[i]][list[j]];
139
- return total;
140
- };
195
+ /* move a block of adjacent users over the run of users behind
196
+ or ahead of it, as soon as this reduces the cost (which only
197
+ the pairs between the block and the run passed contribute
198
+ to, so the cost change is accumulated while extending the
199
+ run), until no such move remains (a single-user move is
200
+ the block move of length one, while the longer blocks let
201
+ groups of users pass an obstacle which none of its users
202
+ could pass on its own) */
141
203
  order = seed.map((_, i) => i);
142
- let total = totalOf(order);
143
204
  for (let improved = true; improved;) {
144
205
  improved = false;
145
- for (let i = 0; i < n && !improved; i++) {
146
- for (let j = 0; j < n && !improved; j++) {
147
- if (i === j)
148
- continue;
149
- const moved = [...order];
150
- moved.splice(j, 0, ...moved.splice(i, 1));
151
- const t = totalOf(moved);
152
- if (t < total) {
153
- order = moved;
154
- total = t;
155
- improved = true;
206
+ for (let len = 1; len < n && !improved; len++) {
207
+ for (let i = 0; i + len <= n && !improved; i++) {
208
+ let delta = 0;
209
+ for (let k = i + len; k < n && !improved; k++) {
210
+ for (let b = i; b < i + len; b++)
211
+ delta += cost[order[k]][order[b]] - cost[order[b]][order[k]];
212
+ if (delta < 0) {
213
+ order.splice(k - len + 1, 0, ...order.splice(i, len));
214
+ improved = true;
215
+ }
216
+ }
217
+ delta = 0;
218
+ for (let k = i - 1; k >= 0 && !improved; k--) {
219
+ for (let b = i; b < i + len; b++)
220
+ delta += cost[order[b]][order[k]] - cost[order[k]][order[b]];
221
+ if (delta < 0) {
222
+ order.splice(k, 0, ...order.splice(i, len));
223
+ improved = true;
224
+ }
156
225
  }
157
226
  }
158
227
  }
@@ -1,10 +1,11 @@
1
1
  import { Node, Graph } from "./gradia-api-model.js";
2
2
  import { Config } from "./gradia-api-config.js";
3
- import { Layout } from "./gradia-api-render-svg.js";
3
+ import { Layout } from "./gradia-api-render-base.js";
4
+ import { Containment } from "./gradia-api-render-container.js";
4
5
  export declare const groupOf: (node: Node) => string | undefined;
5
6
  export interface GroupPart {
6
7
  name: string;
7
8
  graph: Graph;
8
9
  }
9
- export declare const partitionGroups: (graph: Graph) => GroupPart[] | null;
10
+ export declare const partitionGroups: (graph: Graph, containment: Containment) => GroupPart[] | null;
10
11
  export declare const composeGroups: (parts: GroupPart[], layouts: Layout[], config: Config) => Layout;
@@ -4,22 +4,31 @@
4
4
  ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
5
  */
6
6
  import { FS_GROUP, textWidth } from "./gradia-api-render-base.js";
7
+ import { defaultStyleOf } from "./gradia-api-render-node.js";
8
+ import { rootOf, boundsOf } from "./gradia-api-render-container.js";
7
9
  /* rendering geometry constants */
8
10
  const GROUP_HEAD = 34; /* extra top space for the group tag */
9
11
  /* the special "group" annotation (assigning a node to a named group) */
10
12
  export const groupOf = (node) => node.attrs.findLast((attr) => attr.key === "group")?.val;
11
13
  /* partition the graph into its named groups (in order of first
12
14
  appearance), implying "group: default" for the nodes without one,
13
- or return null when no node uses a group at all. Edges have to
14
- stay within a single group, so reject any group-crossing edge */
15
- export const partitionGroups = (graph) => {
15
+ or return null when no node uses a group at all. A nested node
16
+ belongs to the group of its outermost container, so reject any
17
+ deviating group of its own. Edges have to stay within a single
18
+ group, so reject any group-crossing edge */
19
+ export const partitionGroups = (graph, containment) => {
16
20
  const nodes = Array.from(graph.nodes.values());
17
21
  if (!nodes.some((node) => groupOf(node) !== undefined))
18
22
  return null;
19
23
  const parts = new Map();
20
24
  const nameOf = new Map();
21
25
  for (const node of nodes) {
22
- const name = groupOf(node) ?? "default";
26
+ const root = rootOf(containment, node.id);
27
+ const name = groupOf(graph.nodes.get(root)) ?? "default";
28
+ const own = groupOf(node);
29
+ if (own !== undefined && own !== name)
30
+ throw new Error(`node "${node.id}" cannot be a member of group "${own}", ` +
31
+ `as its container "${root}" is a member of group "${name}"`);
23
32
  nameOf.set(node.id, name);
24
33
  let part = parts.get(name);
25
34
  if (part === undefined) {
@@ -38,26 +47,6 @@ export const partitionGroups = (graph) => {
38
47
  }
39
48
  return Array.from(parts.entries()).map(([name, subgraph]) => ({ name, graph: subgraph }));
40
49
  };
41
- /* determine the bounding box of a laid out group (nodes and edges) */
42
- const boundsOf = (layout) => {
43
- const { nodes, cx, cy, boxW, boxH, polys } = layout;
44
- let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
45
- for (const node of nodes) {
46
- minX = Math.min(minX, cx(node.id) - boxW.get(node.id) / 2);
47
- minY = Math.min(minY, cy(node.id) - boxH.get(node.id) / 2);
48
- maxX = Math.max(maxX, cx(node.id) + boxW.get(node.id) / 2);
49
- maxY = Math.max(maxY, cy(node.id) + boxH.get(node.id) / 2);
50
- }
51
- for (const poly of polys) {
52
- for (const [px, py] of poly) {
53
- minX = Math.min(minX, px);
54
- minY = Math.min(minY, py);
55
- maxX = Math.max(maxX, px);
56
- maxY = Math.max(maxY, py);
57
- }
58
- }
59
- return { minX, minY, maxX, maxY };
60
- };
61
50
  /* compose the per-group layouts into a single layout: the groups are
62
51
  stacked vertically, each one shifted into its surrounding group box
63
52
  (a node id is unique across the groups, as every node is a member
@@ -98,8 +87,8 @@ export const composeGroups = (parts, layouts, config) => {
98
87
  boxH: merge((layout) => layout.boxH),
99
88
  contentH: merge((layout) => layout.contentH),
100
89
  polys: layouts.flatMap((layout, i) => layout.polys.map((poly) => poly.map(([px, py]) => [px + dxs[i], py + dys[i]]))),
101
- styleOf: layouts.every((layout) => layout.styleOf !== undefined) ?
102
- (node) => layouts[groupIdxOf(node.id)].styleOf(node) : undefined,
103
- groups
90
+ styleOf: (node) => (layouts[groupIdxOf(node.id)].styleOf ?? defaultStyleOf)(node),
91
+ groups,
92
+ containers: layouts.flatMap((layout, i) => (layout.containers ?? []).map((c) => ({ ...c, x: c.x + dxs[i], y: c.y + dys[i] })))
104
93
  };
105
94
  };
@@ -4,6 +4,11 @@ import { NodeStyle } from "./gradia-api-render-base.js";
4
4
  export declare const isPrimary: (node: Node) => boolean;
5
5
  export declare const defaultStyleOf: (node: Node) => NodeStyle;
6
6
  export declare const typeOf: (node: Node) => string | undefined;
7
+ export declare const parentOf: (node: Node) => string | undefined;
8
+ export declare const containerTypeOf: (node: Node) => string | undefined;
9
+ export declare const HEAD_H = 34;
10
+ export declare const HEAD_T = 20;
11
+ export declare const containerHead: (node: Node) => number;
7
12
  export declare const attrsOfNode: (node: Node) => Attr[];
8
13
  export declare const urlOf: (node: Node) => string | undefined;
9
14
  export declare const PAD_W = 18;
@@ -20,7 +25,10 @@ export interface NodeLines {
20
25
  attrs: string[];
21
26
  }
22
27
  export declare const linesOfNode: (node: Node, config: Config) => NodeLines;
23
- export declare const measureNodes: (nodes: Node[], config: Config, scaleOf: (node: Node) => number) => {
28
+ export declare const measureNodes: (nodes: Node[], config: Config, scaleOf: (node: Node) => number, fixed?: Map<string, {
29
+ w: number;
30
+ h: number;
31
+ }>) => {
24
32
  boxW: Map<string, number>;
25
33
  boxH: Map<string, number>;
26
34
  contentH: Map<string, number>;