create-pathfinder 4.2.0 → 4.3.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.
- package/CLAUDE.md +2 -0
- package/package.json +1 -1
- package/skills/learn-codebase/SKILL.md +188 -17
- package/skills/learn-feature/SKILL.md +136 -15
- package/skills/map-system/SKILL.md +293 -0
- package/skills/render-artifact/SKILL.md +187 -0
- package/skills/render-artifact/engine/bin/render.mjs +225 -0
- package/skills/render-artifact/engine/deliver.mjs +197 -0
- package/skills/render-artifact/engine/doctor.mjs +96 -0
- package/skills/render-artifact/engine/examples/diagram.json +223 -0
- package/skills/render-artifact/engine/examples/lesson.json +242 -0
- package/skills/render-artifact/engine/references/determinism.md +71 -0
- package/skills/render-artifact/engine/references/specification.md +149 -0
- package/skills/render-artifact/engine/references/validation.md +268 -0
- package/skills/render-artifact/engine/render/behavior.mjs +128 -0
- package/skills/render-artifact/engine/render/diagram.mjs +342 -0
- package/skills/render-artifact/engine/render/escape.mjs +34 -0
- package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
- package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
- package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
- package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
- package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
- package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
- package/skills/render-artifact/engine/render/index.mjs +50 -0
- package/skills/render-artifact/engine/render/lesson.mjs +294 -0
- package/skills/render-artifact/engine/render/shell.mjs +275 -0
- package/skills/render-artifact/engine/render/theme.mjs +592 -0
- package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
- package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
- package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
- package/skills/render-artifact/engine/validate/composition.mjs +395 -0
- package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
- package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
- package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
- package/skills/render-artifact/engine/validate/index.mjs +132 -0
- package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
- package/skills/render-artifact/engine/validate/structural.mjs +241 -0
- package/skills/render-artifact/engine/verification.mjs +76 -0
- package/skills/render-artifact/engine/version.mjs +24 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where everything goes. The renderer's half of the boundary, in integers.
|
|
3
|
+
*
|
|
4
|
+
* The producer said what exists and what relates to what. Nothing it wrote
|
|
5
|
+
* mentions a coordinate, and nothing here asks it to: rank, order, axis,
|
|
6
|
+
* position, size and route are all computed from the graph's own shape.
|
|
7
|
+
*
|
|
8
|
+
* Four rules keep this deterministic, and they are the reason the algorithms
|
|
9
|
+
* below are duller than they could be:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Every number is an integer.** Not rounded on the way out — integer all
|
|
12
|
+
* the way through. No transcendental math, so nothing depends on an engine's
|
|
13
|
+
* `Math.sin`; no division that does not floor; no floating-point comparison
|
|
14
|
+
* anywhere. Barycentres are compared as exact fractions by cross-multiplying
|
|
15
|
+
* two integers, never by dividing them.
|
|
16
|
+
* 2. **Order comes from the specification.** Every list is walked in the order
|
|
17
|
+
* it was written, every traversal takes its frontier in that order, and ties
|
|
18
|
+
* are broken by it. No `Set` iteration decides anything.
|
|
19
|
+
* 3. **Iteration is a fixed number of passes.** Crossing reduction stops after
|
|
20
|
+
* `ORDER_PASSES` because it was told to, not because a tolerance was met —
|
|
21
|
+
* a convergence test is exactly where floating-point sensitivity gets in.
|
|
22
|
+
* 4. **Nothing ambient.** No clock, no randomness, no viewport, no text
|
|
23
|
+
* measurement, no locale. The one thing this module imports is the pinned
|
|
24
|
+
* character-width table, which is generated data and literal ranges — never
|
|
25
|
+
* a question asked of the engine it is running on.
|
|
26
|
+
*
|
|
27
|
+
* The axis is chosen from the graph's shape and from nothing else: a graph
|
|
28
|
+
* deeper than it is wide reads top-to-bottom, and a graph wider than it is deep
|
|
29
|
+
* reads left-to-right. The viewport never enters into it — a delivered artifact
|
|
30
|
+
* has one geometry, and the browser adapts by scaling rather than by relaying.
|
|
31
|
+
*
|
|
32
|
+
* Box sizing here is provisional and deliberately generous. The width-aware
|
|
33
|
+
* model that makes it correct for every script, and the decision to freeze it
|
|
34
|
+
* or fall back to uniform boxes, belong to the ticket that owns typography.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { cellWidth, codePointWidth } from "./width.mjs";
|
|
38
|
+
|
|
39
|
+
/** The grid. Every dimension a multiple of 4, so every derived value stays whole. */
|
|
40
|
+
export const GEOMETRY = Object.freeze({
|
|
41
|
+
NODE_W: 208,
|
|
42
|
+
NODE_H: 76,
|
|
43
|
+
RANK_GAP: 88,
|
|
44
|
+
// Wide enough that two boundaries in neighbouring column bands cannot touch:
|
|
45
|
+
// each pads itself by GROUP_PAD on both sides, so the gap has to clear twice
|
|
46
|
+
// that. The same arithmetic holds along the ranks, where RANK_GAP clears
|
|
47
|
+
// GROUP_PAD twice over plus the room a boundary's label needs.
|
|
48
|
+
ORDER_GAP: 64,
|
|
49
|
+
MARGIN: 56,
|
|
50
|
+
GROUP_PAD: 24,
|
|
51
|
+
GROUP_HEAD: 28,
|
|
52
|
+
DETOUR: 44,
|
|
53
|
+
/** Fixed, never a convergence test. */
|
|
54
|
+
ORDER_PASSES: 4,
|
|
55
|
+
/**
|
|
56
|
+
* The label budget, in columns, and the lines a node box has room for.
|
|
57
|
+
*
|
|
58
|
+
* These two multiply to the node label's hard cap of 32 columns, and that is
|
|
59
|
+
* not a coincidence — it is what makes wrapping lossless by construction
|
|
60
|
+
* rather than by luck. Any label the schema accepts fits in two lines of
|
|
61
|
+
* sixteen, so there is never a remainder to drop, overflow, or apologise for.
|
|
62
|
+
*/
|
|
63
|
+
LABEL_CELLS_PER_LINE: 16,
|
|
64
|
+
LABEL_MAX_LINES: 2,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} Layout
|
|
69
|
+
* @property {number} width canvas width, integer
|
|
70
|
+
* @property {number} height canvas height, integer
|
|
71
|
+
* @property {"vertical"|"horizontal"} axis which way ranks advance
|
|
72
|
+
* @property {number} rankCount
|
|
73
|
+
* @property {object[]} nodes each with id, box {x,y,w,h}, rank, order
|
|
74
|
+
* @property {object[]} edges each with id, points [[x,y],...], and its shape
|
|
75
|
+
* @property {object[]} groups each with id, box {x,y,w,h}, depth
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lay out a `graph` topology.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} diagram the specification's `diagram` object, already validated
|
|
82
|
+
* @returns {Layout}
|
|
83
|
+
*/
|
|
84
|
+
export function layoutGraph(diagram) {
|
|
85
|
+
const nodes = diagram.nodes;
|
|
86
|
+
const edges = diagram.edges;
|
|
87
|
+
const groups = diagram.groups ?? [];
|
|
88
|
+
|
|
89
|
+
const indexOf = new Map();
|
|
90
|
+
nodes.forEach((node, i) => indexOf.set(node.id, i));
|
|
91
|
+
|
|
92
|
+
const ranks = assignRanks(nodes, edges, indexOf);
|
|
93
|
+
const rankCount = ranks.reduce((most, rank) => (rank > most ? rank : most), 0) + 1;
|
|
94
|
+
|
|
95
|
+
const allocation = allocateBands(nodes, groups);
|
|
96
|
+
const order = orderWithinRanks(nodes, edges, indexOf, ranks, rankCount, allocation.bandOf);
|
|
97
|
+
const columns = measureBands(nodes, ranks, rankCount, allocation);
|
|
98
|
+
|
|
99
|
+
// Shape decides the axis, and shape is the only thing that may. A graph with
|
|
100
|
+
// more ranks than it has columns is a progression, and a progression reads
|
|
101
|
+
// downward.
|
|
102
|
+
const axis = rankCount > columns.total ? "vertical" : "horizontal";
|
|
103
|
+
|
|
104
|
+
const placement = placeNodes(nodes, ranks, order, rankCount, allocation, columns, axis);
|
|
105
|
+
const boxes = placement.boxes;
|
|
106
|
+
const groupBoxes = placeGroups(groups, allocation, columns, placement, axis);
|
|
107
|
+
const extent = canvasExtent(boxes, groupBoxes);
|
|
108
|
+
|
|
109
|
+
// Everything was laid out from the origin; shift once so the margin is real
|
|
110
|
+
// and no coordinate is negative.
|
|
111
|
+
const dx = GEOMETRY.MARGIN - extent.minX;
|
|
112
|
+
const dy = GEOMETRY.MARGIN - extent.minY;
|
|
113
|
+
for (const box of boxes) { box.x += dx; box.y += dy; }
|
|
114
|
+
for (const box of groupBoxes) { box.box.x += dx; box.box.y += dy; }
|
|
115
|
+
|
|
116
|
+
const placed = nodes.map((node, i) => ({
|
|
117
|
+
id: node.id,
|
|
118
|
+
rank: ranks[i],
|
|
119
|
+
order: order[i],
|
|
120
|
+
box: boxes[i],
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
width: extent.maxX - extent.minX + GEOMETRY.MARGIN * 2,
|
|
125
|
+
height: extent.maxY - extent.minY + GEOMETRY.MARGIN * 2,
|
|
126
|
+
axis,
|
|
127
|
+
rankCount,
|
|
128
|
+
nodes: placed,
|
|
129
|
+
groups: groupBoxes,
|
|
130
|
+
edges: routeEdges(edges, indexOf, boxes, ranks, axis),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Longest-path ranking over the graph with its back edges set aside.
|
|
136
|
+
*
|
|
137
|
+
* Cycles are legal — a retry loop is a fact, not a modelling error — so they
|
|
138
|
+
* are broken for ranking only, and only by a depth-first walk that visits
|
|
139
|
+
* nodes and their neighbours in specification order. The same specification
|
|
140
|
+
* therefore breaks the same edge every time.
|
|
141
|
+
*/
|
|
142
|
+
function assignRanks(nodes, edges, indexOf) {
|
|
143
|
+
const forward = nodes.map(() => []);
|
|
144
|
+
const indegree = nodes.map(() => 0);
|
|
145
|
+
|
|
146
|
+
for (const edge of forwardEdges(nodes, edges, indexOf)) {
|
|
147
|
+
forward[edge.from].push(edge.to);
|
|
148
|
+
indegree[edge.to] += 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Kahn, with the frontier taken in specification order rather than as a
|
|
152
|
+
// queue of whatever finished last.
|
|
153
|
+
const rank = nodes.map(() => 0);
|
|
154
|
+
const remaining = indegree.slice();
|
|
155
|
+
const settled = nodes.map(() => false);
|
|
156
|
+
|
|
157
|
+
for (let done = 0; done < nodes.length; done += 1) {
|
|
158
|
+
let next = -1;
|
|
159
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
160
|
+
if (!settled[i] && remaining[i] === 0) { next = i; break; }
|
|
161
|
+
}
|
|
162
|
+
// Unreachable while back edges are excluded: the remaining graph is a DAG.
|
|
163
|
+
if (next < 0) break;
|
|
164
|
+
|
|
165
|
+
settled[next] = true;
|
|
166
|
+
for (const target of forward[next]) {
|
|
167
|
+
if (rank[next] + 1 > rank[target]) rank[target] = rank[next] + 1;
|
|
168
|
+
remaining[target] -= 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return rank;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The edges that may carry rank, as `{from, to}` index pairs.
|
|
177
|
+
*
|
|
178
|
+
* Self-edges never can. A back edge — one that closes a cycle during a
|
|
179
|
+
* specification-order depth-first walk — is excluded too, and stays a fully
|
|
180
|
+
* drawn relationship; it just does not get a say in how deep its target sits.
|
|
181
|
+
*/
|
|
182
|
+
function forwardEdges(nodes, edges, indexOf) {
|
|
183
|
+
const adjacency = nodes.map(() => []);
|
|
184
|
+
edges.forEach((edge, e) => {
|
|
185
|
+
const from = indexOf.get(edge.from);
|
|
186
|
+
const to = indexOf.get(edge.to);
|
|
187
|
+
if (from === undefined || to === undefined || from === to) return;
|
|
188
|
+
adjacency[from].push({ to, e });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
192
|
+
const colour = nodes.map(() => WHITE);
|
|
193
|
+
const isBack = edges.map(() => false);
|
|
194
|
+
|
|
195
|
+
for (let start = 0; start < nodes.length; start += 1) {
|
|
196
|
+
if (colour[start] !== WHITE) continue;
|
|
197
|
+
colour[start] = GREY;
|
|
198
|
+
const stack = [{ node: start, cursor: 0 }];
|
|
199
|
+
while (stack.length > 0) {
|
|
200
|
+
const frame = stack[stack.length - 1];
|
|
201
|
+
if (frame.cursor >= adjacency[frame.node].length) {
|
|
202
|
+
colour[frame.node] = BLACK;
|
|
203
|
+
stack.pop();
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const { to, e } = adjacency[frame.node][frame.cursor];
|
|
207
|
+
frame.cursor += 1;
|
|
208
|
+
if (colour[to] === GREY) { isBack[e] = true; continue; }
|
|
209
|
+
if (colour[to] === WHITE) {
|
|
210
|
+
colour[to] = GREY;
|
|
211
|
+
stack.push({ node: to, cursor: 0 });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const out = [];
|
|
217
|
+
edges.forEach((edge, e) => {
|
|
218
|
+
if (isBack[e]) return;
|
|
219
|
+
const from = indexOf.get(edge.from);
|
|
220
|
+
const to = indexOf.get(edge.to);
|
|
221
|
+
if (from === undefined || to === undefined || from === to) return;
|
|
222
|
+
out.push({ from, to });
|
|
223
|
+
});
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Give every boundary its own columns.
|
|
229
|
+
*
|
|
230
|
+
* A group drawn as the bounding box of wherever its members happened to land is
|
|
231
|
+
* a lie as soon as the members are not adjacent: the box swallows nodes that
|
|
232
|
+
* are not in it and asserts a membership the specification never claimed. So
|
|
233
|
+
* the columns are partitioned up front instead. Each boundary gets a band of
|
|
234
|
+
* columns of its own, every rank places that boundary's members inside that
|
|
235
|
+
* band, and a boundary's box is therefore a clean rectangle that contains its
|
|
236
|
+
* members and nothing else. Two boundaries cannot overlap because their bands
|
|
237
|
+
* do not.
|
|
238
|
+
*
|
|
239
|
+
* Bands are allocated in one fixed order — ungrouped first, then each root
|
|
240
|
+
* boundary in specification order, and inside a root, its own direct members
|
|
241
|
+
* before each of its children in specification order. That is the whole of the
|
|
242
|
+
* grouping algorithm. It costs width, which is the right thing to spend to stop
|
|
243
|
+
* a boundary claiming something untrue.
|
|
244
|
+
*/
|
|
245
|
+
function allocateBands(nodes, groups) {
|
|
246
|
+
const position = new Map();
|
|
247
|
+
groups.forEach((group, g) => position.set(group.id, g));
|
|
248
|
+
|
|
249
|
+
const roots = groups.filter((group) =>
|
|
250
|
+
group.parent === undefined || !position.has(group.parent));
|
|
251
|
+
const childrenOf = new Map();
|
|
252
|
+
for (const group of groups) childrenOf.set(group.id, []);
|
|
253
|
+
for (const group of groups) {
|
|
254
|
+
if (group.parent === undefined) continue;
|
|
255
|
+
const siblings = childrenOf.get(group.parent);
|
|
256
|
+
if (siblings && siblings !== childrenOf.get(group.id)) siblings.push(group.id);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Leaf bands, in allocation order. Each owns a contiguous run of columns. */
|
|
260
|
+
const bands = [];
|
|
261
|
+
const bandOfGroup = new Map();
|
|
262
|
+
|
|
263
|
+
const ungrouped = nodes.some((node) =>
|
|
264
|
+
node.group === undefined || !position.has(node.group));
|
|
265
|
+
if (ungrouped) bands.push({ owner: null });
|
|
266
|
+
|
|
267
|
+
for (const root of roots) {
|
|
268
|
+
const direct = nodes.some((node) => node.group === root.id);
|
|
269
|
+
if (direct) {
|
|
270
|
+
bandOfGroup.set(root.id, bands.length);
|
|
271
|
+
bands.push({ owner: root.id });
|
|
272
|
+
}
|
|
273
|
+
for (const childId of childrenOf.get(root.id)) {
|
|
274
|
+
if (!nodes.some((node) => node.group === childId)) continue;
|
|
275
|
+
bandOfGroup.set(childId, bands.length);
|
|
276
|
+
bands.push({ owner: childId });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const bandOf = nodes.map((node) => {
|
|
281
|
+
if (node.group === undefined || !position.has(node.group)) return 0;
|
|
282
|
+
const at = bandOfGroup.get(node.group);
|
|
283
|
+
return at === undefined ? 0 : at;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return { bands, bandOf, bandOfGroup, childrenOf, roots };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Crossing reduction: a fixed number of barycentre passes, alternating
|
|
291
|
+
* direction, seeded from specification order.
|
|
292
|
+
*
|
|
293
|
+
* Barycentres are fractions. Comparing them by dividing would put the layout at
|
|
294
|
+
* the mercy of floating point, so they are compared by cross-multiplying two
|
|
295
|
+
* integers instead — exact, and identical on every engine. A node with no
|
|
296
|
+
* neighbour in the rank being consulted keeps the position it already has,
|
|
297
|
+
* which is both a sensible answer and a total order.
|
|
298
|
+
*/
|
|
299
|
+
function orderWithinRanks(nodes, edges, indexOf, ranks, rankCount, bandOf) {
|
|
300
|
+
const members = [];
|
|
301
|
+
for (let r = 0; r < rankCount; r += 1) members.push([]);
|
|
302
|
+
nodes.forEach((node, i) => members[ranks[i]].push(i));
|
|
303
|
+
|
|
304
|
+
const predecessors = nodes.map(() => []);
|
|
305
|
+
const successors = nodes.map(() => []);
|
|
306
|
+
for (const edge of edges) {
|
|
307
|
+
const from = indexOf.get(edge.from);
|
|
308
|
+
const to = indexOf.get(edge.to);
|
|
309
|
+
if (from === undefined || to === undefined || from === to) continue;
|
|
310
|
+
if (ranks[from] === ranks[to]) continue;
|
|
311
|
+
predecessors[to].push(from);
|
|
312
|
+
successors[from].push(to);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const position = nodes.map(() => 0);
|
|
316
|
+
const reposition = () => {
|
|
317
|
+
for (const rank of members) rank.forEach((node, at) => { position[node] = at; });
|
|
318
|
+
};
|
|
319
|
+
reposition();
|
|
320
|
+
|
|
321
|
+
for (let pass = 0; pass < GEOMETRY.ORDER_PASSES; pass += 1) {
|
|
322
|
+
const downward = pass % 2 === 0;
|
|
323
|
+
const sequence = [];
|
|
324
|
+
for (let r = 0; r < rankCount; r += 1) sequence.push(downward ? r : rankCount - 1 - r);
|
|
325
|
+
|
|
326
|
+
for (const r of sequence) {
|
|
327
|
+
const neighboursOf = downward ? predecessors : successors;
|
|
328
|
+
const keys = new Map();
|
|
329
|
+
for (const node of members[r]) {
|
|
330
|
+
const relevant = neighboursOf[node].filter((other) =>
|
|
331
|
+
downward ? ranks[other] < r : ranks[other] > r);
|
|
332
|
+
keys.set(node, relevant.length === 0
|
|
333
|
+
? { num: position[node], den: 1 }
|
|
334
|
+
: { num: relevant.reduce((sum, other) => sum + position[other], 0),
|
|
335
|
+
den: relevant.length });
|
|
336
|
+
}
|
|
337
|
+
// `sort` is stable, so equal keys keep the order they already had, which
|
|
338
|
+
// on the first pass is the order the specification wrote.
|
|
339
|
+
members[r].sort((a, b) => {
|
|
340
|
+
const ga = bandOf[a];
|
|
341
|
+
const gb = bandOf[b];
|
|
342
|
+
if (ga !== gb) return ga - gb;
|
|
343
|
+
const ka = keys.get(a);
|
|
344
|
+
const kb = keys.get(b);
|
|
345
|
+
return ka.num * kb.den - kb.num * ka.den;
|
|
346
|
+
});
|
|
347
|
+
members[r].forEach((node, at) => { position[node] = at; });
|
|
348
|
+
}
|
|
349
|
+
reposition();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const order = nodes.map(() => 0);
|
|
353
|
+
for (const rank of members) rank.forEach((node, at) => { order[node] = at; });
|
|
354
|
+
return order;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* How many columns each band needs, and where its run starts.
|
|
359
|
+
*
|
|
360
|
+
* A band is as wide as its busiest rank. Offsets are the running total, so the
|
|
361
|
+
* runs are contiguous, disjoint, and in allocation order.
|
|
362
|
+
*/
|
|
363
|
+
function measureBands(nodes, ranks, rankCount, allocation) {
|
|
364
|
+
const width = allocation.bands.map(() => 0);
|
|
365
|
+
|
|
366
|
+
for (let b = 0; b < allocation.bands.length; b += 1) {
|
|
367
|
+
for (let r = 0; r < rankCount; r += 1) {
|
|
368
|
+
let count = 0;
|
|
369
|
+
nodes.forEach((node, i) => {
|
|
370
|
+
if (allocation.bandOf[i] === b && ranks[i] === r) count += 1;
|
|
371
|
+
});
|
|
372
|
+
if (count > width[b]) width[b] = count;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const offset = [];
|
|
377
|
+
let running = 0;
|
|
378
|
+
for (const w of width) { offset.push(running); running += w; }
|
|
379
|
+
return { width, offset, total: running === 0 ? 1 : running };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Integer coordinates on a fixed grid.
|
|
384
|
+
*
|
|
385
|
+
* `along` advances with the rank. `across` is the node's column: its band's
|
|
386
|
+
* offset, plus its position among that band's members in this rank, centred
|
|
387
|
+
* inside the band so a rank that uses less than the band's full width sits in
|
|
388
|
+
* the middle of it rather than jammed to one side. The halving is a floor, so
|
|
389
|
+
* an odd remainder lands the same way on every machine.
|
|
390
|
+
*/
|
|
391
|
+
function placeNodes(nodes, ranks, order, rankCount, allocation, columns, axis) {
|
|
392
|
+
const vertical = axis === "vertical";
|
|
393
|
+
const alongExtent = vertical ? GEOMETRY.NODE_H : GEOMETRY.NODE_W;
|
|
394
|
+
const acrossExtent = vertical ? GEOMETRY.NODE_W : GEOMETRY.NODE_H;
|
|
395
|
+
const alongStep = alongExtent + GEOMETRY.RANK_GAP;
|
|
396
|
+
const acrossStep = acrossExtent + GEOMETRY.ORDER_GAP;
|
|
397
|
+
|
|
398
|
+
// Position within the band, for this rank, in the order the ordering pass
|
|
399
|
+
// settled on.
|
|
400
|
+
const local = nodes.map(() => 0);
|
|
401
|
+
const counts = new Map();
|
|
402
|
+
const sorted = nodes.map((_, i) => i)
|
|
403
|
+
.sort((a, b) => (ranks[a] - ranks[b]) || (order[a] - order[b]));
|
|
404
|
+
for (const i of sorted) {
|
|
405
|
+
const key = `${allocation.bandOf[i]}:${ranks[i]}`;
|
|
406
|
+
const at = counts.get(key) ?? 0;
|
|
407
|
+
local[i] = at;
|
|
408
|
+
counts.set(key, at + 1);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const boxes = nodes.map((node, i) => {
|
|
412
|
+
const band = allocation.bandOf[i];
|
|
413
|
+
const used = counts.get(`${band}:${ranks[i]}`) ?? 0;
|
|
414
|
+
const centring = Math.floor(((columns.width[band] - used) * acrossStep) / 2);
|
|
415
|
+
const across = (columns.offset[band] * acrossStep) + centring + local[i] * acrossStep;
|
|
416
|
+
const along = ranks[i] * alongStep;
|
|
417
|
+
return vertical
|
|
418
|
+
? { x: across, y: along, w: GEOMETRY.NODE_W, h: GEOMETRY.NODE_H }
|
|
419
|
+
: { x: along, y: across, w: GEOMETRY.NODE_W, h: GEOMETRY.NODE_H };
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
return { boxes, alongStep, acrossStep, alongExtent, acrossExtent, ranks };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A boundary's box: its band's columns, across the ranks its members occupy.
|
|
427
|
+
*
|
|
428
|
+
* Because the band is the boundary's own, this rectangle contains its members
|
|
429
|
+
* and can contain nothing else. A root boundary with children covers its own
|
|
430
|
+
* band and theirs, which are adjacent by construction, so the union is still
|
|
431
|
+
* one rectangle.
|
|
432
|
+
*/
|
|
433
|
+
function placeGroups(groups, allocation, columns, placement, axis) {
|
|
434
|
+
if (groups.length === 0) return [];
|
|
435
|
+
const vertical = axis === "vertical";
|
|
436
|
+
const { acrossStep, alongStep, alongExtent, ranks } = placement;
|
|
437
|
+
|
|
438
|
+
const bandsOf = (group) => {
|
|
439
|
+
const own = allocation.bandOfGroup.get(group.id);
|
|
440
|
+
const list = own === undefined ? [] : [own];
|
|
441
|
+
for (const childId of allocation.childrenOf.get(group.id) ?? []) {
|
|
442
|
+
const at = allocation.bandOfGroup.get(childId);
|
|
443
|
+
if (at !== undefined) list.push(at);
|
|
444
|
+
}
|
|
445
|
+
return list;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const memberRanks = (group) => {
|
|
449
|
+
const ids = [group.id, ...(allocation.childrenOf.get(group.id) ?? [])];
|
|
450
|
+
const wanted = new Set(ids.map((id) => allocation.bandOfGroup.get(id))
|
|
451
|
+
.filter((b) => b !== undefined));
|
|
452
|
+
const out = [];
|
|
453
|
+
ranks.forEach((rank, i) => {
|
|
454
|
+
if (wanted.has(allocation.bandOf[i])) out.push(rank);
|
|
455
|
+
});
|
|
456
|
+
return out;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const out = [];
|
|
460
|
+
for (const group of groups) {
|
|
461
|
+
const bands = bandsOf(group);
|
|
462
|
+
const rankList = memberRanks(group);
|
|
463
|
+
if (bands.length === 0 || rankList.length === 0) continue;
|
|
464
|
+
|
|
465
|
+
const firstColumn = Math.min(...bands.map((b) => columns.offset[b]));
|
|
466
|
+
const lastColumn = Math.max(...bands.map((b) => columns.offset[b] + columns.width[b]));
|
|
467
|
+
const firstRank = Math.min(...rankList);
|
|
468
|
+
const lastRank = Math.max(...rankList);
|
|
469
|
+
|
|
470
|
+
const acrossStart = firstColumn * acrossStep - GEOMETRY.GROUP_PAD;
|
|
471
|
+
const acrossSize = (lastColumn - firstColumn) * acrossStep - GEOMETRY.ORDER_GAP
|
|
472
|
+
+ GEOMETRY.GROUP_PAD * 2;
|
|
473
|
+
const alongStart = firstRank * alongStep - GEOMETRY.GROUP_PAD - GEOMETRY.GROUP_HEAD;
|
|
474
|
+
const alongSize = (lastRank - firstRank) * alongStep + alongExtent
|
|
475
|
+
+ GEOMETRY.GROUP_PAD * 2 + GEOMETRY.GROUP_HEAD;
|
|
476
|
+
|
|
477
|
+
const depth = group.parent === undefined ? 0 : 1;
|
|
478
|
+
const box = vertical
|
|
479
|
+
? { x: acrossStart, y: alongStart, w: acrossSize, h: alongSize }
|
|
480
|
+
: { x: alongStart, y: acrossStart, w: alongSize, h: acrossSize };
|
|
481
|
+
|
|
482
|
+
// A boundary holding other boundaries needs room around them, or it shares
|
|
483
|
+
// a corner with its first child and the two labels sit on top of each
|
|
484
|
+
// other. One more pad all round, and one more label's height at the start.
|
|
485
|
+
if (depth === 0 && bands.length > 1) {
|
|
486
|
+
const lead = GEOMETRY.GROUP_PAD + GEOMETRY.GROUP_HEAD;
|
|
487
|
+
if (vertical) {
|
|
488
|
+
box.x -= GEOMETRY.GROUP_PAD; box.w += GEOMETRY.GROUP_PAD * 2;
|
|
489
|
+
box.y -= lead; box.h += lead + GEOMETRY.GROUP_PAD;
|
|
490
|
+
} else {
|
|
491
|
+
box.y -= GEOMETRY.GROUP_PAD; box.h += GEOMETRY.GROUP_PAD * 2;
|
|
492
|
+
box.x -= lead; box.w += lead + GEOMETRY.GROUP_PAD;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
out.push({ id: group.id, depth, box });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Parents behind children, each tier in specification order.
|
|
500
|
+
return [...out.filter((g) => g.depth === 0), ...out.filter((g) => g.depth === 1)];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function canvasExtent(boxes, groupBoxes) {
|
|
504
|
+
const all = [...boxes, ...groupBoxes.map((g) => g.box)];
|
|
505
|
+
let minX = all[0].x, minY = all[0].y;
|
|
506
|
+
let maxX = all[0].x + all[0].w, maxY = all[0].y + all[0].h;
|
|
507
|
+
for (const box of all) {
|
|
508
|
+
if (box.x < minX) minX = box.x;
|
|
509
|
+
if (box.y < minY) minY = box.y;
|
|
510
|
+
if (box.x + box.w > maxX) maxX = box.x + box.w;
|
|
511
|
+
if (box.y + box.h > maxY) maxY = box.y + box.h;
|
|
512
|
+
}
|
|
513
|
+
return { minX, minY, maxX, maxY };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Orthogonal routes, as integer waypoints.
|
|
518
|
+
*
|
|
519
|
+
* Three shapes, and which one an edge gets is decided by the ranks it joins,
|
|
520
|
+
* never by the producer:
|
|
521
|
+
*
|
|
522
|
+
* forward down one side, across the gap between ranks, into the next
|
|
523
|
+
* lateral out to the side and back in, for an edge inside one rank
|
|
524
|
+
* loop a small rectangle, for a node that relates to itself
|
|
525
|
+
*
|
|
526
|
+
* Every point is an integer because every input is. The midpoint between two
|
|
527
|
+
* ranks is a floor, so it lands on the same pixel everywhere.
|
|
528
|
+
*/
|
|
529
|
+
function routeEdges(edges, indexOf, boxes, ranks, axis) {
|
|
530
|
+
const vertical = axis === "vertical";
|
|
531
|
+
|
|
532
|
+
return edges.map((edge) => {
|
|
533
|
+
const from = indexOf.get(edge.from);
|
|
534
|
+
const to = indexOf.get(edge.to);
|
|
535
|
+
const a = boxes[from];
|
|
536
|
+
const b = boxes[to];
|
|
537
|
+
|
|
538
|
+
if (from === to) {
|
|
539
|
+
return { id: edge.id, shape: "loop", points: loopPoints(a, vertical) };
|
|
540
|
+
}
|
|
541
|
+
if (ranks[from] === ranks[to]) {
|
|
542
|
+
return { id: edge.id, shape: "lateral", points: lateralPoints(a, b, vertical) };
|
|
543
|
+
}
|
|
544
|
+
return { id: edge.id, shape: "forward", points: forwardPoints(a, b, vertical) };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function centreAlong(box, vertical) {
|
|
549
|
+
return vertical ? box.x + Math.floor(box.w / 2) : box.y + Math.floor(box.h / 2);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function forwardPoints(a, b, vertical) {
|
|
553
|
+
const descending = vertical ? b.y >= a.y : b.x >= a.x;
|
|
554
|
+
if (vertical) {
|
|
555
|
+
const ax = centreAlong(a, true);
|
|
556
|
+
const bx = centreAlong(b, true);
|
|
557
|
+
const ay = descending ? a.y + a.h : a.y;
|
|
558
|
+
const by = descending ? b.y : b.y + b.h;
|
|
559
|
+
const mid = ay + Math.floor((by - ay) / 2);
|
|
560
|
+
if (ax === bx) return [[ax, ay], [bx, by]];
|
|
561
|
+
return [[ax, ay], [ax, mid], [bx, mid], [bx, by]];
|
|
562
|
+
}
|
|
563
|
+
const ay = centreAlong(a, false);
|
|
564
|
+
const by = centreAlong(b, false);
|
|
565
|
+
const ax = descending ? a.x + a.w : a.x;
|
|
566
|
+
const bx = descending ? b.x : b.x + b.w;
|
|
567
|
+
const mid = ax + Math.floor((bx - ax) / 2);
|
|
568
|
+
if (ay === by) return [[ax, ay], [bx, by]];
|
|
569
|
+
return [[ax, ay], [mid, ay], [mid, by], [bx, by]];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* An edge inside one rank leaves the way it came in and goes over the top.
|
|
574
|
+
*
|
|
575
|
+
* Two nodes in the same rank sit side by side with other nodes possibly between
|
|
576
|
+
* them, so the route steps out of the rank entirely rather than trying to find
|
|
577
|
+
* a gap. Four points, all integers, and no case analysis beyond the axis.
|
|
578
|
+
*/
|
|
579
|
+
function lateralPoints(a, b, vertical) {
|
|
580
|
+
const d = GEOMETRY.DETOUR;
|
|
581
|
+
if (vertical) {
|
|
582
|
+
const ax = a.x + Math.floor(a.w / 2);
|
|
583
|
+
const bx = b.x + Math.floor(b.w / 2);
|
|
584
|
+
const above = Math.min(a.y, b.y) - d;
|
|
585
|
+
return [[ax, a.y], [ax, above], [bx, above], [bx, b.y]];
|
|
586
|
+
}
|
|
587
|
+
const ay = a.y + Math.floor(a.h / 2);
|
|
588
|
+
const by = b.y + Math.floor(b.h / 2);
|
|
589
|
+
const left = Math.min(a.x, b.x) - d;
|
|
590
|
+
return [[a.x, ay], [left, ay], [left, by], [b.x, by]];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function loopPoints(box, vertical) {
|
|
594
|
+
const d = GEOMETRY.DETOUR;
|
|
595
|
+
if (vertical) {
|
|
596
|
+
const y1 = box.y + Math.floor(box.h / 3);
|
|
597
|
+
const y2 = box.y + Math.floor((box.h * 2) / 3);
|
|
598
|
+
const x = box.x + box.w;
|
|
599
|
+
return [[x, y1], [x + d, y1], [x + d, y2], [x, y2]];
|
|
600
|
+
}
|
|
601
|
+
const x1 = box.x + Math.floor(box.w / 3);
|
|
602
|
+
const x2 = box.x + Math.floor((box.w * 2) / 3);
|
|
603
|
+
const y = box.y;
|
|
604
|
+
return [[x1, y], [x1, y - d], [x2, y - d], [x2, y]];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Break a label into lines, in columns rather than characters, losing nothing.
|
|
609
|
+
*
|
|
610
|
+
* A character is not a width. `A` and `漢` are one character each and one and
|
|
611
|
+
* two columns; a Devanagari matra is a character and no columns at all. So the
|
|
612
|
+
* budget is spent in columns, counted against the pinned table in `width.mjs`,
|
|
613
|
+
* and never against `String.length` — which is not even a character count, but
|
|
614
|
+
* a count of UTF-16 units.
|
|
615
|
+
*
|
|
616
|
+
* Two stages, and the second is what makes the guarantee structural:
|
|
617
|
+
*
|
|
618
|
+
* 1. **Wrap on spaces.** What anyone would expect of a label with words in it.
|
|
619
|
+
* Word boundaries waste room, though, so this stage can need more lines than
|
|
620
|
+
* the box has — `aaaaaaaaa bbbbbbbbb ccccccccc` is twenty-nine columns and
|
|
621
|
+
* wants three lines of sixteen.
|
|
622
|
+
* 2. **When it does, wrap on columns instead.** The cap is thirty-two columns
|
|
623
|
+
* and a box holds two lines of sixteen, so a column-wrapped label always
|
|
624
|
+
* fits. Exactly, with nothing left over.
|
|
625
|
+
*
|
|
626
|
+
* That is why nothing here truncates, ellipsises, drops a word, or spills past
|
|
627
|
+
* the last line. It is not a rule the code remembers to follow; it is
|
|
628
|
+
* arithmetic the caps and the budget already settled. A label the schema
|
|
629
|
+
* accepts cannot fail to fit.
|
|
630
|
+
*
|
|
631
|
+
* Scripts without spaces reach stage two and are wrapped on column boundaries,
|
|
632
|
+
* which is the correct behaviour for them and needs no dictionary and no
|
|
633
|
+
* locale-sensitive line breaking — both of which are forbidden here and neither
|
|
634
|
+
* of which would be deterministic.
|
|
635
|
+
*/
|
|
636
|
+
export function wrapLabel(text) {
|
|
637
|
+
const budget = GEOMETRY.LABEL_CELLS_PER_LINE;
|
|
638
|
+
const source = String(text);
|
|
639
|
+
if (source === "") return [""];
|
|
640
|
+
|
|
641
|
+
// Stage one is accepted only if it fits on both counts: few enough lines, and
|
|
642
|
+
// no line over budget. Checking the line count alone would pass a single word
|
|
643
|
+
// wider than the box, which has no space to break at and so comes back from
|
|
644
|
+
// the space-wrapper as one long line.
|
|
645
|
+
const byWord = wrapOnSpaces(source, budget);
|
|
646
|
+
const fits = byWord.length <= GEOMETRY.LABEL_MAX_LINES
|
|
647
|
+
&& byWord.every((line) => cellWidth(line) <= budget);
|
|
648
|
+
return fits ? byWord : wrapOnCells(source, budget);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Greedy wrap at spaces. May need more lines than the box has; the caller checks. */
|
|
652
|
+
function wrapOnSpaces(source, budget) {
|
|
653
|
+
const lines = [];
|
|
654
|
+
let current = "";
|
|
655
|
+
let width = 0;
|
|
656
|
+
|
|
657
|
+
for (const word of source.split(" ")) {
|
|
658
|
+
const wordWidth = cellWidth(word);
|
|
659
|
+
if (current === "") { current = word; width = wordWidth; continue; }
|
|
660
|
+
if (width + 1 + wordWidth <= budget) {
|
|
661
|
+
current = `${current} ${word}`;
|
|
662
|
+
width += 1 + wordWidth;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
lines.push(current);
|
|
666
|
+
current = word;
|
|
667
|
+
width = wordWidth;
|
|
668
|
+
}
|
|
669
|
+
if (current !== "") lines.push(current);
|
|
670
|
+
return lines.length === 0 ? [source] : lines;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Wrap at column boundaries, ignoring spaces.
|
|
675
|
+
*
|
|
676
|
+
* A zero-width code point never forces a break and never starts a line: a
|
|
677
|
+
* combining mark belongs to the character it modifies, and moving it to the
|
|
678
|
+
* next line would render it against the wrong base. So the break is decided by
|
|
679
|
+
* the next code point that actually occupies a column.
|
|
680
|
+
*/
|
|
681
|
+
function wrapOnCells(source, budget) {
|
|
682
|
+
const lines = [];
|
|
683
|
+
let current = "";
|
|
684
|
+
let width = 0;
|
|
685
|
+
|
|
686
|
+
for (const character of source) {
|
|
687
|
+
const w = codePointWidth(character.codePointAt(0));
|
|
688
|
+
if (w > 0 && width + w > budget && current !== "") {
|
|
689
|
+
lines.push(current);
|
|
690
|
+
current = "";
|
|
691
|
+
width = 0;
|
|
692
|
+
}
|
|
693
|
+
current += character;
|
|
694
|
+
width += w;
|
|
695
|
+
}
|
|
696
|
+
if (current !== "") lines.push(current);
|
|
697
|
+
return lines.length === 0 ? [source] : lines;
|
|
698
|
+
}
|