@rse/gradia 1.1.9 → 1.2.1
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.
- package/README.md +89 -36
- package/dst/gradia-api-config.d.ts +7 -0
- package/dst/gradia-api-config.js +54 -24
- package/dst/gradia-api-parser.js +10 -4
- package/dst/gradia-api-render-base.d.ts +28 -0
- package/dst/gradia-api-render-base.js +3 -2
- package/dst/gradia-api-render-container.d.ts +31 -0
- package/dst/gradia-api-render-container.js +300 -0
- package/dst/gradia-api-render-edge.d.ts +1 -1
- package/dst/gradia-api-render-edge.js +106 -37
- package/dst/gradia-api-render-group.d.ts +3 -2
- package/dst/gradia-api-render-group.js +16 -27
- package/dst/gradia-api-render-node.d.ts +10 -1
- package/dst/gradia-api-render-node.js +35 -14
- package/dst/gradia-api-render-svg.d.ts +1 -21
- package/dst/gradia-api-render-svg.js +82 -20
- package/dst/gradia-api-type-graph.d.ts +3 -2
- package/dst/gradia-api-type-graph.js +280 -135
- package/dst/gradia-api-type-grid.d.ts +3 -2
- package/dst/gradia-api-type-grid.js +10 -5
- package/dst/gradia-api-type-hub.d.ts +3 -2
- package/dst/gradia-api-type-hub.js +20 -10
- package/dst/gradia-api.d.ts +12 -4
- package/dst/gradia-api.js +33 -13
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +4 -21
- package/dst/gradia-mcp.js +58 -54
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
99
|
-
sets exactly, by a dynamic programming
|
|
100
|
-
linear ordering problem), for larger
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
146
|
-
for (let
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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-
|
|
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.
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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:
|
|
102
|
-
|
|
103
|
-
|
|
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,12 @@ 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;
|
|
12
|
+
export declare const orderOf: (node: Node) => number | undefined;
|
|
7
13
|
export declare const attrsOfNode: (node: Node) => Attr[];
|
|
8
14
|
export declare const urlOf: (node: Node) => string | undefined;
|
|
9
15
|
export declare const PAD_W = 18;
|
|
@@ -20,7 +26,10 @@ export interface NodeLines {
|
|
|
20
26
|
attrs: string[];
|
|
21
27
|
}
|
|
22
28
|
export declare const linesOfNode: (node: Node, config: Config) => NodeLines;
|
|
23
|
-
export declare const measureNodes: (nodes: Node[], config: Config, scaleOf: (node: Node) => number
|
|
29
|
+
export declare const measureNodes: (nodes: Node[], config: Config, scaleOf: (node: Node) => number, fixed?: Map<string, {
|
|
30
|
+
w: number;
|
|
31
|
+
h: number;
|
|
32
|
+
}>) => {
|
|
24
33
|
boxW: Map<string, number>;
|
|
25
34
|
boxH: Map<string, number>;
|
|
26
35
|
contentH: Map<string, number>;
|