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.
Files changed (39) hide show
  1. package/CLAUDE.md +2 -0
  2. package/package.json +1 -1
  3. package/skills/learn-codebase/SKILL.md +188 -17
  4. package/skills/learn-feature/SKILL.md +136 -15
  5. package/skills/map-system/SKILL.md +293 -0
  6. package/skills/render-artifact/SKILL.md +187 -0
  7. package/skills/render-artifact/engine/bin/render.mjs +225 -0
  8. package/skills/render-artifact/engine/deliver.mjs +197 -0
  9. package/skills/render-artifact/engine/doctor.mjs +96 -0
  10. package/skills/render-artifact/engine/examples/diagram.json +223 -0
  11. package/skills/render-artifact/engine/examples/lesson.json +242 -0
  12. package/skills/render-artifact/engine/references/determinism.md +71 -0
  13. package/skills/render-artifact/engine/references/specification.md +149 -0
  14. package/skills/render-artifact/engine/references/validation.md +268 -0
  15. package/skills/render-artifact/engine/render/behavior.mjs +128 -0
  16. package/skills/render-artifact/engine/render/diagram.mjs +342 -0
  17. package/skills/render-artifact/engine/render/escape.mjs +34 -0
  18. package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
  19. package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
  20. package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
  21. package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
  22. package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
  23. package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
  24. package/skills/render-artifact/engine/render/index.mjs +50 -0
  25. package/skills/render-artifact/engine/render/lesson.mjs +294 -0
  26. package/skills/render-artifact/engine/render/shell.mjs +275 -0
  27. package/skills/render-artifact/engine/render/theme.mjs +592 -0
  28. package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
  29. package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
  30. package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
  31. package/skills/render-artifact/engine/validate/composition.mjs +395 -0
  32. package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
  33. package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
  34. package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
  35. package/skills/render-artifact/engine/validate/index.mjs +132 -0
  36. package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
  37. package/skills/render-artifact/engine/validate/structural.mjs +241 -0
  38. package/skills/render-artifact/engine/verification.mjs +76 -0
  39. package/skills/render-artifact/engine/version.mjs +24 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The diagram's inline behaviour: focus, traversal, path highlight, zoom, pan.
3
+ *
4
+ * **Per kind, not shared.** The shell's own script carries the theme toggle,
5
+ * navigation position and quiz feedback, and every artifact gets it. This one
6
+ * is appended only for a diagram, for the same reason `GRAPH_CSS` is: a lesson
7
+ * has no graph, and shipping traversal code to every lesson would put dead
8
+ * script in an artifact that can never run it. The theme toggle in particular
9
+ * is *reused* from the shell and not reimplemented here — a diagram-specific
10
+ * copy would be a second control fighting the first over the same attribute.
11
+ *
12
+ * **Every interaction reads the semantic model.** `interaction.mjs` inlines an
13
+ * index built from the specification's nodes, edges and paths, and traversal
14
+ * runs over that. Nothing here measures, inspects or infers from the picture:
15
+ * no bounding boxes are compared to decide what is downstream, no polyline is
16
+ * asked what it touches. Geometry is an output of the graph, so reading meaning
17
+ * back out of it would make "downstream" depend on where the layout happened to
18
+ * put things.
19
+ *
20
+ * **The script only changes state.** It sets attributes — `data-pf-state`,
21
+ * `data-pf-mode`, `aria-current`, `disabled` — and the stylesheet decides what
22
+ * those look like. It writes one `viewBox` for zoom and pan. It never creates a
23
+ * node, never writes text into the document, and never restates a summary or a
24
+ * citation. That last part is load-bearing for the provenance contract: an
25
+ * interaction that re-rendered evidence could get it wrong, or make it read as
26
+ * stronger than it is. Focusing a component takes the reader to the evidence
27
+ * that is already there, in the words the renderer already chose.
28
+ *
29
+ * With scripting off, every fact is still in the document and the artifact is
30
+ * still a readable, navigable page. What is lost is the ability to ask it
31
+ * questions, which is the definition of an enhancement.
32
+ *
33
+ * Pointer and keyboard panning do read the element's rendered size, because
34
+ * turning a drag in pixels into a movement in user units cannot be done
35
+ * without it. That is a reading of the reader's window at the time they drag;
36
+ * it happens long after the bytes were written and cannot affect them.
37
+ */
38
+
39
+ import { TRAVERSAL_JS } from "./interaction.mjs";
40
+
41
+ /**
42
+ * Renderer-owned interface language. The producer supplies none of it, and
43
+ * there is no field through which it could.
44
+ *
45
+ * The status sentences are deliberately about *structure* — what is selected
46
+ * and how much it reaches — and never about trust. A traversal result is not a
47
+ * verification claim, and no wording here may let it read as one.
48
+ */
49
+ const UI = Object.freeze({
50
+ nothing: "Nothing selected. Choose a component to focus it.",
51
+ cleared: "Selection cleared.",
52
+ });
53
+
54
+ /**
55
+ * @param {string} modelJson the serialized interaction model
56
+ * @returns {string} the diagram's inline script, for the shell's per-kind slot
57
+ */
58
+ export function graphBehavior(modelJson) {
59
+ return `
60
+ (function () {
61
+ "use strict";
62
+
63
+ var model = ${modelJson};
64
+
65
+ ${TRAVERSAL_JS}
66
+
67
+ var canvas = document.querySelector("[data-pf-canvas]");
68
+ var svg = canvas ? canvas.querySelector("[data-pf-graph]") : null;
69
+ if (!canvas || !svg) return;
70
+
71
+ var status = document.querySelector("[data-pf-status]");
72
+
73
+ function all(selector) {
74
+ return Array.prototype.slice.call(document.querySelectorAll(selector));
75
+ }
76
+
77
+ /* The canvas is a picture to assistive technology, so the interactive
78
+ surface is the toolbar and the written reading below it — real buttons,
79
+ in document order, carrying the stylesheet's existing focus ring. Every
80
+ one of them is revealed here rather than shipped visible, because a
81
+ control that cannot work should not be offered: with scripting off a
82
+ reader meets no dead buttons and loses nothing they could have read. */
83
+ all("[data-pf-controls]").forEach(function (group) { group.hidden = false; });
84
+
85
+ var viewBox = svg.getAttribute("viewBox").split(" ");
86
+ var BASE = { w: Number(viewBox[2]), h: Number(viewBox[3]) };
87
+ var MAX_ZOOM = 6;
88
+
89
+ var view = { zoom: 1, x: 0, y: 0 };
90
+ var state = { focus: null, trace: null, path: null };
91
+
92
+ var nodeGroups = all("[data-pf-node]");
93
+ var edgeGroups = all("[data-pf-edge]");
94
+ var entries = all("[data-pf-entry]");
95
+
96
+ /* ---- the view: zoom and pan, clamped to the graph's own bounds ---- */
97
+
98
+ function paint() {
99
+ var w = Math.max(1, Math.floor(BASE.w / view.zoom));
100
+ var h = Math.max(1, Math.floor(BASE.h / view.zoom));
101
+
102
+ /* Clamped to the content, so panning can never wander off into empty
103
+ space and "fit" is always the way back. */
104
+ view.x = Math.min(Math.max(view.x, 0), Math.max(0, BASE.w - w));
105
+ view.y = Math.min(Math.max(view.y, 0), Math.max(0, BASE.h - h));
106
+
107
+ svg.setAttribute("viewBox", view.x + " " + view.y + " " + w + " " + h);
108
+ canvas.setAttribute("data-pf-zoom", view.zoom === 1 ? "fit" : "in");
109
+ limits();
110
+ }
111
+
112
+ function zoomBy(factor) {
113
+ var before = { w: BASE.w / view.zoom, h: BASE.h / view.zoom };
114
+ var next = Math.min(Math.max(view.zoom * factor, 1), MAX_ZOOM);
115
+ if (next === view.zoom) return;
116
+
117
+ var after = { w: BASE.w / next, h: BASE.h / next };
118
+ /* Keep whatever is in the middle of the view in the middle of it. */
119
+ view.x = view.x + Math.floor((before.w - after.w) / 2);
120
+ view.y = view.y + Math.floor((before.h - after.h) / 2);
121
+ view.zoom = next;
122
+ paint();
123
+ }
124
+
125
+ function fit() {
126
+ view.zoom = 1;
127
+ view.x = 0;
128
+ view.y = 0;
129
+ paint();
130
+ }
131
+
132
+ function panBy(dx, dy) {
133
+ view.x = view.x + dx;
134
+ view.y = view.y + dy;
135
+ paint();
136
+ }
137
+
138
+ /* ---- the selection: focus, traversal, path ---- */
139
+
140
+ function endpointsOf(edgeId) {
141
+ return model.edges[edgeId] || null;
142
+ }
143
+
144
+ function selection() {
145
+ var nodes = Object.create(null);
146
+ var near = Object.create(null);
147
+ var edges = Object.create(null);
148
+ var mode = "";
149
+
150
+ if (state.path && model.paths[state.path]) {
151
+ /* Exactly the edges the producer authored, addressed by id. Two edges
152
+ joining one pair are two different claims, and only the one named by
153
+ the path is lit. */
154
+ mode = "path";
155
+ var walked = model.paths[state.path];
156
+ for (var i = 0; i < walked.length; i += 1) {
157
+ edges[walked[i]] = true;
158
+ var ends = endpointsOf(walked[i]);
159
+ if (ends) { near[ends[0]] = true; near[ends[1]] = true; }
160
+ }
161
+ } else if (state.focus && state.trace) {
162
+ mode = "trace";
163
+ var reached = pfTraverse(model, state.focus, state.trace);
164
+ for (var n = 0; n < reached.nodes.length; n += 1) nodes[reached.nodes[n]] = true;
165
+ for (var e = 0; e < reached.edges.length; e += 1) edges[reached.edges[e]] = true;
166
+ } else if (state.focus) {
167
+ mode = "focus";
168
+ nodes[state.focus] = true;
169
+ var sides = [model.out[state.focus] || [], model["in"][state.focus] || []];
170
+ for (var s = 0; s < sides.length; s += 1) {
171
+ for (var k = 0; k < sides[s].length; k += 1) {
172
+ edges[sides[s][k][0]] = true;
173
+ near[sides[s][k][1]] = true;
174
+ }
175
+ }
176
+ }
177
+
178
+ return { mode: mode, nodes: nodes, near: near, edges: edges };
179
+ }
180
+
181
+ function describe(picked) {
182
+ if (picked.mode === "path") {
183
+ return "Path: " + count(model.paths[state.path].length, "relationship") + ", exactly as authored.";
184
+ }
185
+ if (picked.mode === "trace") {
186
+ var word = state.trace === "in" ? "Upstream of " : "Downstream of ";
187
+ return word + label(state.focus) + ": " +
188
+ count(countOf(picked.nodes) - 1, "component") + " reached, " +
189
+ count(countOf(picked.edges), "relationship") + " crossed.";
190
+ }
191
+ if (picked.mode === "focus") {
192
+ return "Focused: " + label(state.focus) + ". " +
193
+ count(countOf(picked.edges), "direct relationship") + ".";
194
+ }
195
+ return ${JSON.stringify(UI.nothing)};
196
+ }
197
+
198
+ function label(id) {
199
+ return model.labels[id] || id;
200
+ }
201
+
202
+ function count(n, noun) {
203
+ return n + " " + noun + (n === 1 ? "" : "s");
204
+ }
205
+
206
+ function countOf(set) {
207
+ return Object.keys(set).length;
208
+ }
209
+
210
+ function mark(elements, attribute, picked) {
211
+ for (var i = 0; i < elements.length; i += 1) {
212
+ var element = elements[i];
213
+ var id = element.getAttribute(attribute);
214
+ if (picked.mode === "") {
215
+ element.removeAttribute("data-pf-state");
216
+ continue;
217
+ }
218
+ var value = picked.nodes[id] ? "on"
219
+ : picked.edges[id] ? "on"
220
+ : picked.near[id] ? "near"
221
+ : "off";
222
+ element.setAttribute("data-pf-state", value);
223
+ }
224
+ }
225
+
226
+ function apply() {
227
+ var picked = selection();
228
+
229
+ canvas.setAttribute("data-pf-mode", picked.mode);
230
+ mark(nodeGroups, "data-pf-node", picked);
231
+ mark(edgeGroups, "data-pf-edge", picked);
232
+
233
+ /* The written reading is where the details and the evidence live. Marking
234
+ the selected entry current is the whole of the details interaction: the
235
+ reader is sent to the evidence already in the document rather than
236
+ shown a second copy of it. */
237
+ for (var i = 0; i < entries.length; i += 1) {
238
+ var entry = entries[i];
239
+ var kind = entry.getAttribute("data-pf-entry");
240
+ var forId = entry.getAttribute("data-pf-for");
241
+ var isCurrent = (kind === "node" && forId === state.focus)
242
+ || (kind === "path" && forId === state.path)
243
+ /* An edge row is current when the highlighted path walks it, which is
244
+ what makes "highlight this path" and "read its evidence" one act
245
+ rather than two. Keyed off the picked edge set, so it is exactly the
246
+ authored edges and never a similar-looking one. */
247
+ || (kind === "edge" && picked.mode === "path" && Boolean(picked.edges[forId]));
248
+ if (isCurrent) {
249
+ entry.setAttribute("aria-current", "true");
250
+ } else {
251
+ entry.removeAttribute("aria-current");
252
+ }
253
+ }
254
+
255
+ if (status) status.textContent = describe(picked);
256
+
257
+ act("upstream", !state.focus);
258
+ act("downstream", !state.focus);
259
+ act("details", !state.focus);
260
+ act("clear", picked.mode === "");
261
+ }
262
+
263
+ /* A control at its limit is disabled rather than left to do nothing when
264
+ pressed. Fit is the floor deliberately: zooming out past the whole graph
265
+ would only add empty space, and "fit" is then always the way back. */
266
+ function limits() {
267
+ act("zoom-out", view.zoom <= 1);
268
+ act("zoom-in", view.zoom >= MAX_ZOOM);
269
+ act("fit", view.zoom === 1 && view.x === 0 && view.y === 0);
270
+ }
271
+
272
+ function act(name, isDisabled) {
273
+ var button = document.querySelector('[data-pf-act="' + name + '"]');
274
+ if (button) button.disabled = Boolean(isDisabled);
275
+ }
276
+
277
+ function focusNode(id, options) {
278
+ if (!model.labels[id]) return;
279
+ state.focus = id;
280
+ state.trace = null;
281
+ state.path = null;
282
+ apply();
283
+ if (options && options.reveal) reveal(id);
284
+ }
285
+
286
+ function reveal(id) {
287
+ var entry = document.querySelector('[data-pf-entry="node"][data-pf-for="' + id + '"]');
288
+ if (!entry) return;
289
+ var open = entry.closest ? entry.closest("details") : null;
290
+ if (open) open.open = true;
291
+ if (entry.scrollIntoView) entry.scrollIntoView({ block: "nearest" });
292
+ }
293
+
294
+ function clear() {
295
+ state.focus = null;
296
+ state.trace = null;
297
+ state.path = null;
298
+ apply();
299
+ if (status) status.textContent = ${JSON.stringify(UI.cleared)};
300
+ }
301
+
302
+ /* ---- wiring ---- */
303
+
304
+ document.addEventListener("click", function (event) {
305
+ var target = event.target;
306
+ if (!target || !target.closest) return;
307
+
308
+ var control = target.closest("[data-pf-act]");
309
+ if (control && !control.disabled) {
310
+ var action = control.getAttribute("data-pf-act");
311
+ if (action === "zoom-in") zoomBy(1.5);
312
+ else if (action === "zoom-out") zoomBy(1 / 1.5);
313
+ else if (action === "fit") fit();
314
+ else if (action === "reset") { fit(); clear(); }
315
+ else if (action === "upstream") { state.trace = "in"; state.path = null; apply(); }
316
+ else if (action === "downstream") { state.trace = "out"; state.path = null; apply(); }
317
+ else if (action === "details") reveal(state.focus);
318
+ else if (action === "clear") clear();
319
+ else if (action === "path") {
320
+ state.path = control.getAttribute("data-pf-path");
321
+ state.focus = null;
322
+ state.trace = null;
323
+ apply();
324
+ }
325
+ return;
326
+ }
327
+
328
+ /* A written entry's own button: the keyboard route into focus. */
329
+ var pick = target.closest("[data-pf-pick]");
330
+ if (pick) {
331
+ focusNode(pick.getAttribute("data-pf-pick"), { reveal: false });
332
+ return;
333
+ }
334
+
335
+ /* A node in the picture: the pointer route. Keyboard readers reach the
336
+ same state through the entry buttons above, which is why nothing in
337
+ the canvas is a tab stop. */
338
+ var drawn = target.closest("[data-pf-node]");
339
+ if (drawn) focusNode(drawn.getAttribute("data-pf-node"), { reveal: true });
340
+ });
341
+
342
+ /* Drag to pan. Pixels become user units through the element's rendered
343
+ width, which is a reading of the reader's window and not of anything
344
+ that decided the artifact's bytes. */
345
+ var dragging = null;
346
+ svg.addEventListener("pointerdown", function (event) {
347
+ if (view.zoom === 1) return;
348
+ dragging = { x: event.clientX, y: event.clientY };
349
+ canvas.setAttribute("data-pf-dragging", "true");
350
+ if (svg.setPointerCapture) svg.setPointerCapture(event.pointerId);
351
+ });
352
+ svg.addEventListener("pointermove", function (event) {
353
+ if (!dragging) return;
354
+ var rect = svg.getBoundingClientRect();
355
+ if (!rect.width || !rect.height) return;
356
+ var scaleX = (BASE.w / view.zoom) / rect.width;
357
+ var scaleY = (BASE.h / view.zoom) / rect.height;
358
+ panBy(
359
+ Math.floor((dragging.x - event.clientX) * scaleX),
360
+ Math.floor((dragging.y - event.clientY) * scaleY));
361
+ dragging = { x: event.clientX, y: event.clientY };
362
+ });
363
+ function endDrag() {
364
+ dragging = null;
365
+ canvas.removeAttribute("data-pf-dragging");
366
+ }
367
+ svg.addEventListener("pointerup", endDrag);
368
+ svg.addEventListener("pointercancel", endDrag);
369
+
370
+ /* Keyboard panning, on the canvas itself, which is a tab stop for exactly
371
+ this reason. Escape clears from anywhere. */
372
+ canvas.addEventListener("keydown", function (event) {
373
+ var step = Math.max(16, Math.floor(BASE.w / view.zoom / 8));
374
+ var moved = true;
375
+ if (event.key === "ArrowLeft") panBy(-step, 0);
376
+ else if (event.key === "ArrowRight") panBy(step, 0);
377
+ else if (event.key === "ArrowUp") panBy(0, -step);
378
+ else if (event.key === "ArrowDown") panBy(0, step);
379
+ else if (event.key === "+" || event.key === "=") zoomBy(1.5);
380
+ else if (event.key === "-") zoomBy(1 / 1.5);
381
+ else if (event.key === "0") fit();
382
+ else moved = false;
383
+ if (moved) event.preventDefault();
384
+ });
385
+
386
+ document.addEventListener("keydown", function (event) {
387
+ if (event.key === "Escape") clear();
388
+ });
389
+
390
+ paint();
391
+ apply();
392
+ })();
393
+ `.trim();
394
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The layout, as SVG.
3
+ *
4
+ * This module turns integers into markup and makes no spatial decisions of its
5
+ * own: every coordinate it emits came from `layout.mjs`, and every word it
6
+ * emits about the *subject* came from the specification. The words about the
7
+ * *interface* — the role names under each node, the arrowhead, the title text
8
+ * a screen reader hears — are the renderer's, defined here, the same way the
9
+ * shell owns the chrome around a lesson.
10
+ *
11
+ * Each drawn thing carries the identifier it was authored under —
12
+ * `data-pf-node`, `data-pf-edge`, `data-pf-group`. That is the only handle the
13
+ * reading interactions use. They never ask the picture what is next to what:
14
+ * geometry is an output of the graph, so inferring meaning back out of it would
15
+ * make "downstream" depend on where the layout happened to put things.
16
+ *
17
+ * Role decides shape and stroke, and never colour alone: a diagram whose
18
+ * meaning is carried by hue is a diagram half its readers cannot use. Each node
19
+ * carries its role as a word, so the distinction survives greyscale, a
20
+ * colour-vision difference, and a printer.
21
+ */
22
+
23
+ import { esc, domId } from "../escape.mjs";
24
+ import { GEOMETRY, wrapLabel } from "./layout.mjs";
25
+
26
+ /** Renderer-owned interface language for the canvas itself. */
27
+ const UI = Object.freeze({
28
+ roles: {
29
+ actor: "actor",
30
+ interface: "interface",
31
+ service: "service",
32
+ store: "store",
33
+ queue: "queue",
34
+ job: "job",
35
+ external: "external",
36
+ step: "step",
37
+ decision: "decision",
38
+ terminal: "terminal",
39
+ },
40
+ relations: {
41
+ calls: "calls",
42
+ reads: "reads",
43
+ writes: "writes",
44
+ publishes: "publishes",
45
+ consumes: "consumes",
46
+ depends_on: "depends on",
47
+ transitions_to: "becomes",
48
+ triggers: "triggers",
49
+ },
50
+ canvasLabel: "Diagram",
51
+ });
52
+
53
+ /** Corner radius by role. Shape carries meaning; colour only reinforces it. */
54
+ const RADIUS = Object.freeze({
55
+ actor: 38, external: 38, terminal: 38,
56
+ store: 6, queue: 6,
57
+ decision: 20,
58
+ interface: 10, service: 10, job: 10, step: 10,
59
+ });
60
+
61
+ /**
62
+ * @param {object} diagram the validated `diagram` object
63
+ * @param {object} layout the geometry `layoutGraph` computed for it
64
+ * @returns {string} one inline `<svg>` element
65
+ */
66
+ export function drawGraph(diagram, layout) {
67
+ const boxOf = new Map(layout.nodes.map((node) => [node.id, node.box]));
68
+ const nodeById = new Map(diagram.nodes.map((node) => [node.id, node]));
69
+ const edgeById = new Map(diagram.edges.map((edge) => [edge.id, edge]));
70
+ const groupById = new Map((diagram.groups ?? []).map((group) => [group.id, group]));
71
+
72
+ // Which edges an authored path walks. This is the whole of emphasis: the
73
+ // producer said which walk matters, and the renderer decides it gets a
74
+ // heavier stroke. There is no emphasis field and there must never be one.
75
+ const onPath = new Set();
76
+ for (const path of diagram.paths ?? []) {
77
+ for (const id of path.edges) onPath.add(id);
78
+ }
79
+
80
+ const titleId = domId("pf", "diagram", "title");
81
+ const out = [
82
+ `<svg class="pf-graph" viewBox="0 0 ${layout.width} ${layout.height}" ` +
83
+ `role="img" aria-labelledby="${esc(titleId)}" ` +
84
+ `xmlns="http://www.w3.org/2000/svg">`,
85
+ `<title id="${esc(titleId)}">${esc(UI.canvasLabel)}</title>`,
86
+ arrowDefs(),
87
+ ];
88
+
89
+ for (const placed of layout.groups) {
90
+ out.push(drawGroup(groupById.get(placed.id), placed));
91
+ }
92
+ for (const route of layout.edges) {
93
+ out.push(drawEdge(edgeById.get(route.id), route, onPath.has(route.id), boxOf));
94
+ }
95
+ for (const placed of layout.nodes) {
96
+ out.push(drawNode(nodeById.get(placed.id), placed));
97
+ }
98
+
99
+ out.push("</svg>");
100
+ return out.join("\n");
101
+ }
102
+
103
+ /**
104
+ * One arrowhead, defined once and referenced by every edge.
105
+ *
106
+ * `userSpaceOnUse` rather than the stroke-scaled default, so the head is the
107
+ * same size on a heavy path stroke as on a light one — an emphasised edge
108
+ * should read as emphasised, not as pointing harder.
109
+ */
110
+ function arrowDefs() {
111
+ return [
112
+ "<defs>",
113
+ '<marker id="pf-arrow" markerWidth="10" markerHeight="10" refX="9" refY="4" ' +
114
+ 'markerUnits="userSpaceOnUse" orient="auto">',
115
+ '<path d="M 0 0 L 9 4 L 0 8 z" fill="context-stroke"/>',
116
+ "</marker>",
117
+ "</defs>",
118
+ ].join("\n");
119
+ }
120
+
121
+ function drawGroup(group, placed) {
122
+ const { box } = placed;
123
+ const id = domId("g", group.id);
124
+ return [
125
+ `<g class="pf-group" data-pf-group="${esc(group.id)}" ` +
126
+ `data-pf-depth="${placed.depth}">`,
127
+ `<rect class="pf-group-box" id="${esc(id)}" x="${box.x}" y="${box.y}" ` +
128
+ `width="${box.w}" height="${box.h}" rx="14"/>`,
129
+ `<text class="pf-group-label" x="${box.x + GEOMETRY.GROUP_PAD}" ` +
130
+ `y="${box.y + 20}">${esc(group.label)}</text>`,
131
+ "</g>",
132
+ ].join("\n");
133
+ }
134
+
135
+ function drawNode(node, placed) {
136
+ const { box } = placed;
137
+ const id = domId("n", node.id);
138
+ const lines = wrapLabel(node.label);
139
+ const centreX = box.x + Math.floor(box.w / 2);
140
+ // The label block is centred on the box, then the role word sits below it.
141
+ const lineHeight = 18;
142
+ const blockTop = box.y + Math.floor((box.h - lines.length * lineHeight) / 2) + 2;
143
+
144
+ const text = lines.map((line, i) =>
145
+ `<tspan x="${centreX}" y="${blockTop + i * lineHeight}">${esc(line)}</tspan>`);
146
+
147
+ return [
148
+ `<g class="pf-node" data-pf-node="${esc(node.id)}" ` +
149
+ `data-pf-role="${esc(node.role)}">`,
150
+ `<rect class="pf-node-box" id="${esc(id)}" x="${box.x}" y="${box.y}" ` +
151
+ `width="${box.w}" height="${box.h}" rx="${RADIUS[node.role]}"/>`,
152
+ `<text class="pf-node-label" text-anchor="middle">${text.join("")}</text>`,
153
+ `<text class="pf-node-role" text-anchor="middle" x="${centreX}" ` +
154
+ `y="${box.y + box.h - 12}">${esc(UI.roles[node.role])}</text>`,
155
+ "</g>",
156
+ ].join("\n");
157
+ }
158
+
159
+ function drawEdge(edge, route, emphasised, boxOf) {
160
+ const points = route.points.map(([x, y]) => `${x},${y}`).join(" ");
161
+ const id = domId("e", edge.id);
162
+ const classes = emphasised ? "pf-edge pf-edge-on-path" : "pf-edge";
163
+
164
+ const out = [
165
+ `<g class="${classes}" data-pf-edge="${esc(edge.id)}" ` +
166
+ `data-pf-relation="${esc(edge.relation)}" ` +
167
+ `data-pf-shape="${esc(route.shape)}">`,
168
+ `<polyline class="pf-edge-line" id="${esc(id)}" points="${points}" ` +
169
+ `marker-end="url(#pf-arrow)"/>`,
170
+ ];
171
+
172
+ const label = edge.label ?? UI.relations[edge.relation];
173
+ const anchor = labelAnchor(route.points);
174
+ out.push(
175
+ `<text class="pf-edge-label" text-anchor="middle" x="${anchor[0]}" ` +
176
+ `y="${anchor[1]}">${esc(label)}</text>`);
177
+
178
+ out.push("</g>");
179
+ return out.join("\n");
180
+ }
181
+
182
+ /**
183
+ * Where an edge's label sits: the midpoint of the route's longest segment.
184
+ *
185
+ * Longest because that is the segment with room for text, and the midpoint of
186
+ * it because there is nowhere better that does not need to measure the label.
187
+ * Ties go to the earlier segment, so the choice is a property of the route
188
+ * rather than of the order a comparison happened to run in.
189
+ */
190
+ function labelAnchor(points) {
191
+ let best = 0;
192
+ let bestLength = -1;
193
+ for (let i = 1; i < points.length; i += 1) {
194
+ const length = Math.abs(points[i][0] - points[i - 1][0])
195
+ + Math.abs(points[i][1] - points[i - 1][1]);
196
+ if (length > bestLength) { bestLength = length; best = i; }
197
+ }
198
+ const a = points[best - 1];
199
+ const b = points[best];
200
+ return [
201
+ a[0] + Math.floor((b[0] - a[0]) / 2),
202
+ a[1] + Math.floor((b[1] - a[1]) / 2) - 6,
203
+ ];
204
+ }