PyDecisionGraph 0.3.2.post1__cp315-cp315t-win_amd64.whl

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 (148) hide show
  1. decision_graph/__infra__.pxd +1 -0
  2. decision_graph/__init__.pxd +1 -0
  3. decision_graph/__init__.py +39 -0
  4. decision_graph/decision_tree/__init__.pxd +2 -0
  5. decision_graph/decision_tree/__init__.py +85 -0
  6. decision_graph/decision_tree/bake/__infra__.pxd +502 -0
  7. decision_graph/decision_tree/bake/__init__.pxd +502 -0
  8. decision_graph/decision_tree/bake/__init__.py +29 -0
  9. decision_graph/decision_tree/bake/c_action.cp315t-win_amd64.pyd +0 -0
  10. decision_graph/decision_tree/bake/c_action.h +324 -0
  11. decision_graph/decision_tree/bake/c_action.pxd +54 -0
  12. decision_graph/decision_tree/bake/c_action.pyi +126 -0
  13. decision_graph/decision_tree/bake/c_action.pyx +65 -0
  14. decision_graph/decision_tree/bake/c_allocator_protocol.cp315t-win_amd64.pyd +0 -0
  15. decision_graph/decision_tree/bake/c_allocator_protocol.pxd +24 -0
  16. decision_graph/decision_tree/bake/c_allocator_protocol.pyi +61 -0
  17. decision_graph/decision_tree/bake/c_allocator_protocol.pyx +109 -0
  18. decision_graph/decision_tree/bake/c_bake.cp315t-win_amd64.pyd +0 -0
  19. decision_graph/decision_tree/bake/c_bake.h +629 -0
  20. decision_graph/decision_tree/bake/c_bake.pxd +52 -0
  21. decision_graph/decision_tree/bake/c_bake.pyi +151 -0
  22. decision_graph/decision_tree/bake/c_bake.pyx +142 -0
  23. decision_graph/decision_tree/bake/c_collections.cp315t-win_amd64.pyd +0 -0
  24. decision_graph/decision_tree/bake/c_collections.h +692 -0
  25. decision_graph/decision_tree/bake/c_collections.pxd +52 -0
  26. decision_graph/decision_tree/bake/c_collections.pyi +193 -0
  27. decision_graph/decision_tree/bake/c_collections.pyx +161 -0
  28. decision_graph/decision_tree/bake/c_const.cp315t-win_amd64.pyd +0 -0
  29. decision_graph/decision_tree/bake/c_const.h +395 -0
  30. decision_graph/decision_tree/bake/c_const.pxd +56 -0
  31. decision_graph/decision_tree/bake/c_const.pyi +305 -0
  32. decision_graph/decision_tree/bake/c_const.pyx +278 -0
  33. decision_graph/decision_tree/bake/c_edge.cp315t-win_amd64.pyd +0 -0
  34. decision_graph/decision_tree/bake/c_edge.h +511 -0
  35. decision_graph/decision_tree/bake/c_edge.pxd +117 -0
  36. decision_graph/decision_tree/bake/c_edge.pyi +192 -0
  37. decision_graph/decision_tree/bake/c_edge.pyx +270 -0
  38. decision_graph/decision_tree/bake/c_eval.h +848 -0
  39. decision_graph/decision_tree/bake/c_eval.pxd +40 -0
  40. decision_graph/decision_tree/bake/c_expr.cp315t-win_amd64.pyd +0 -0
  41. decision_graph/decision_tree/bake/c_expr.h +1900 -0
  42. decision_graph/decision_tree/bake/c_expr.pxd +129 -0
  43. decision_graph/decision_tree/bake/c_expr.pyi +280 -0
  44. decision_graph/decision_tree/bake/c_expr.pyx +272 -0
  45. decision_graph/decision_tree/bake/c_hierarchy.cp315t-win_amd64.pyd +0 -0
  46. decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
  47. decision_graph/decision_tree/bake/c_hierarchy.pxd +71 -0
  48. decision_graph/decision_tree/bake/c_hierarchy.pyi +409 -0
  49. decision_graph/decision_tree/bake/c_hierarchy.pyx +277 -0
  50. decision_graph/decision_tree/bake/c_logic_group.cp315t-win_amd64.pyd +0 -0
  51. decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
  52. decision_graph/decision_tree/bake/c_logic_group.pxd +162 -0
  53. decision_graph/decision_tree/bake/c_logic_group.pyi +264 -0
  54. decision_graph/decision_tree/bake/c_logic_group.pyx +308 -0
  55. decision_graph/decision_tree/bake/c_node.cp315t-win_amd64.pyd +0 -0
  56. decision_graph/decision_tree/bake/c_node.h +2660 -0
  57. decision_graph/decision_tree/bake/c_node.pxd +381 -0
  58. decision_graph/decision_tree/bake/c_node.pyi +438 -0
  59. decision_graph/decision_tree/bake/c_node.pyx +639 -0
  60. decision_graph/decision_tree/bake/c_reconstruct.cp315t-win_amd64.pyd +0 -0
  61. decision_graph/decision_tree/bake/c_reconstruct.pxd +63 -0
  62. decision_graph/decision_tree/bake/c_reconstruct.pyi +60 -0
  63. decision_graph/decision_tree/bake/c_reconstruct.pyx +252 -0
  64. decision_graph/decision_tree/bake/c_var.cp315t-win_amd64.pyd +0 -0
  65. decision_graph/decision_tree/bake/c_var.h +2054 -0
  66. decision_graph/decision_tree/bake/c_var.pxd +205 -0
  67. decision_graph/decision_tree/bake/c_var.pyi +271 -0
  68. decision_graph/decision_tree/bake/c_var.pyx +228 -0
  69. decision_graph/decision_tree/capi/__infra__.pxd +33 -0
  70. decision_graph/decision_tree/capi/__init__.pxd +33 -0
  71. decision_graph/decision_tree/capi/__init__.py +57 -0
  72. decision_graph/decision_tree/capi/c_abc.cp315t-win_amd64.pyd +0 -0
  73. decision_graph/decision_tree/capi/c_abc.pxd +246 -0
  74. decision_graph/decision_tree/capi/c_abc.pyi +770 -0
  75. decision_graph/decision_tree/capi/c_abc.pyx +1532 -0
  76. decision_graph/decision_tree/capi/c_collection.cp315t-win_amd64.pyd +0 -0
  77. decision_graph/decision_tree/capi/c_collection.pxd +22 -0
  78. decision_graph/decision_tree/capi/c_collection.pyi +245 -0
  79. decision_graph/decision_tree/capi/c_collection.pyx +138 -0
  80. decision_graph/decision_tree/capi/c_node.cp315t-win_amd64.pyd +0 -0
  81. decision_graph/decision_tree/capi/c_node.pxd +95 -0
  82. decision_graph/decision_tree/capi/c_node.pyi +492 -0
  83. decision_graph/decision_tree/capi/c_node.pyx +551 -0
  84. decision_graph/decision_tree/exc.py +189 -0
  85. decision_graph/decision_tree/native/__init__.py +57 -0
  86. decision_graph/decision_tree/native/abc.py +1167 -0
  87. decision_graph/decision_tree/native/collection.py +134 -0
  88. decision_graph/decision_tree/native/node.py +521 -0
  89. decision_graph/includes/decision_graph/decision_tree/bake/c_action.c +16320 -0
  90. decision_graph/includes/decision_graph/decision_tree/bake/c_action.h +324 -0
  91. decision_graph/includes/decision_graph/decision_tree/bake/c_allocator_protocol.c +12473 -0
  92. decision_graph/includes/decision_graph/decision_tree/bake/c_bake.c +16485 -0
  93. decision_graph/includes/decision_graph/decision_tree/bake/c_bake.h +629 -0
  94. decision_graph/includes/decision_graph/decision_tree/bake/c_collections.c +17381 -0
  95. decision_graph/includes/decision_graph/decision_tree/bake/c_collections.h +692 -0
  96. decision_graph/includes/decision_graph/decision_tree/bake/c_const.c +20815 -0
  97. decision_graph/includes/decision_graph/decision_tree/bake/c_const.h +395 -0
  98. decision_graph/includes/decision_graph/decision_tree/bake/c_edge.c +20696 -0
  99. decision_graph/includes/decision_graph/decision_tree/bake/c_edge.h +511 -0
  100. decision_graph/includes/decision_graph/decision_tree/bake/c_eval.h +848 -0
  101. decision_graph/includes/decision_graph/decision_tree/bake/c_expr.c +22065 -0
  102. decision_graph/includes/decision_graph/decision_tree/bake/c_expr.h +1900 -0
  103. decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.c +23267 -0
  104. decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
  105. decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.c +22434 -0
  106. decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
  107. decision_graph/includes/decision_graph/decision_tree/bake/c_node.c +28385 -0
  108. decision_graph/includes/decision_graph/decision_tree/bake/c_node.h +2660 -0
  109. decision_graph/includes/decision_graph/decision_tree/bake/c_reconstruct.c +15955 -0
  110. decision_graph/includes/decision_graph/decision_tree/bake/c_var.c +16881 -0
  111. decision_graph/includes/decision_graph/decision_tree/bake/c_var.h +2054 -0
  112. decision_graph/includes/decision_graph/decision_tree/capi/c_abc.c +59196 -0
  113. decision_graph/includes/decision_graph/decision_tree/capi/c_collection.c +19967 -0
  114. decision_graph/includes/decision_graph/decision_tree/capi/c_node.c +39229 -0
  115. decision_graph/logic_group/__init__.py +25 -0
  116. decision_graph/logic_group/base.py +100 -0
  117. decision_graph/logic_group/pending_request.py +384 -0
  118. decision_graph/webui/__init__.py +24 -0
  119. decision_graph/webui/bake/__init__.py +65 -0
  120. decision_graph/webui/bake/app.py +488 -0
  121. decision_graph/webui/bake/static/d3.v7.min.js +2 -0
  122. decision_graph/webui/bake/static/script.js +1440 -0
  123. decision_graph/webui/bake/static/style.css +920 -0
  124. decision_graph/webui/bake/templates/index.html +183 -0
  125. decision_graph/webui/bake/templates/offline.html +184 -0
  126. decision_graph/webui/capi/__init__.py +54 -0
  127. decision_graph/webui/capi/app.py +366 -0
  128. decision_graph/webui/capi/main.py +53 -0
  129. decision_graph/webui/capi/static/d3.v7.min.js +2 -0
  130. decision_graph/webui/capi/static/script.js +1026 -0
  131. decision_graph/webui/capi/static/style.css +656 -0
  132. decision_graph/webui/capi/templates/index.html +93 -0
  133. decision_graph/webui/capi/templates/offline.html +94 -0
  134. decision_graph/webui/native/__init__.py +54 -0
  135. decision_graph/webui/native/app.py +366 -0
  136. decision_graph/webui/native/main.py +53 -0
  137. decision_graph/webui/native/static/d3.v7.min.js +2 -0
  138. decision_graph/webui/native/static/script.js +1026 -0
  139. decision_graph/webui/native/static/style.css +656 -0
  140. decision_graph/webui/native/templates/index.html +93 -0
  141. decision_graph/webui/native/templates/offline.html +94 -0
  142. pydecisiongraph-0.3.2.post1.dist-info/METADATA +136 -0
  143. pydecisiongraph-0.3.2.post1.dist-info/RECORD +148 -0
  144. pydecisiongraph-0.3.2.post1.dist-info/WHEEL +5 -0
  145. pydecisiongraph-0.3.2.post1.dist-info/licenses/LICENSE +373 -0
  146. pydecisiongraph-0.3.2.post1.dist-info/top_level.txt +2 -0
  147. tests/bake_c_evaluator.cp315t-win_amd64.pyd +0 -0
  148. tests/bake_eval_probe.cp315t-win_amd64.pyd +0 -0
@@ -0,0 +1,1440 @@
1
+ /* ==========================================================================
2
+ Bake Graph — the page script.
3
+
4
+ The graph arrives as a card model (see webui/bake/app.py) and is drawn as an
5
+ SVG of cards on a grid. There is no simulation and no force layout: a node's
6
+ place is a function of its depth and its order among its siblings, snapped to
7
+ a fixed cell, so the same graph lands in the same place every time and an
8
+ export is the picture on screen rather than a second drawing of it.
9
+ ========================================================================== */
10
+
11
+ (function () {
12
+ 'use strict';
13
+
14
+ // ---- Card geometry. The grid the layout snaps to. ----
15
+ //
16
+ // The card's size belongs to the reader rather than to the layout: a node's
17
+ // display text is as long as it is, and a card that cannot grow has to cut
18
+ // it off. So the WIDTH is what the text wraps to and the HEIGHT is how much
19
+ // of it fits - a card scaled by a transform would only make the same
20
+ // overflow bigger.
21
+ const BAND_H = 22;
22
+ const CARD_PAD = 12;
23
+ const REPR_LINE_H = 16;
24
+ const META_ROW_H = 24; // The strip along the bottom the metadata sits in.
25
+ const CHAR_W = 6.55; // One character of the display text, in the mono font.
26
+ const CORNER = 9;
27
+ const CHIP_CHARS = 30; // A condition chip is on the edge, not on a card.
28
+
29
+ const ZOOM_MIN = 0.15;
30
+ const ZOOM_MAX = 3;
31
+ const ZOOM_STEP = 1.25;
32
+
33
+ const SVG_NS = 'http://www.w3.org/2000/svg';
34
+
35
+ // ---- Presentation: what the sliders drive. ----
36
+ const PRESENTATION_DEFAULTS = {
37
+ levelGap: 84, // Between one level and the next: the edge's length.
38
+ siblingGap: 48, // Between two cards of the same level.
39
+ cardWidth: 232, // What a display text wraps to.
40
+ cardHeight: 100, // How much of it fits.
41
+ edgeWidth: 1.6, // Stroke width of a link.
42
+ animSpeed: 100, // Percent: 100 is the base duration, 1 is a hundredth.
43
+ };
44
+ const presentation = { ...PRESENTATION_DEFAULTS };
45
+
46
+ /** The size a card ends up on screen, and the cell it takes with it. */
47
+ const cardW = () => presentation.cardWidth;
48
+ const cardH = () => presentation.cardHeight;
49
+ const cellX = () => cardW() + presentation.siblingGap;
50
+ const cellY = () => cardH() + presentation.levelGap;
51
+
52
+ /** How much display text a card of the current size holds. */
53
+ const charsPerLine = () => Math.max(6, Math.floor((cardW() - CARD_PAD * 2) / CHAR_W));
54
+ const reprLines = () => Math.max(1, Math.floor((cardH() - BAND_H - META_ROW_H) / REPR_LINE_H));
55
+
56
+ // How long a layout animation runs at speed x1. The slider scales it: x0.01
57
+ // is a hundred times slower, which is a debugging speed rather than a
58
+ // viewing one.
59
+ const ANIM_BASE_MS = 300;
60
+
61
+ // ---- State ----
62
+ const state = {
63
+ data: null,
64
+ byId: new Map(),
65
+ refLinks: [],
66
+ orientation: 'top-down',
67
+ theme: 'dark',
68
+ highlight: true,
69
+ group: '*',
70
+ activeIds: new Set(),
71
+ hasWalk: false,
72
+ walk: null,
73
+ collapsed: new Set(),
74
+ positions: new Map(),
75
+ edgeKeys: new Set(),
76
+ selected: null,
77
+ failedId: null,
78
+ zoom: null,
79
+ cards: new Map(),
80
+ edges: [],
81
+ };
82
+
83
+ // ---- DOM ----
84
+ const el = {
85
+ svg: document.getElementById('graph'),
86
+ canvas: document.getElementById('canvas'),
87
+ emptyNote: document.getElementById('empty-note'),
88
+ titleName: document.getElementById('title-name'),
89
+ statNodes: document.getElementById('stat-nodes'),
90
+ statWalked: document.getElementById('stat-walked'),
91
+ statMode: document.getElementById('stat-mode'),
92
+ groups: document.getElementById('ctl-groups'),
93
+ inspector: document.getElementById('inspector'),
94
+ arms: document.getElementById('ins-arms'),
95
+ operands: document.getElementById('ins-operands'),
96
+ pathBox: document.getElementById('ctl-path'),
97
+ clipboardHint: document.getElementById('clipboard-hint'),
98
+ outCode: document.getElementById('out-code'),
99
+ outLength: document.getElementById('out-length'),
100
+ outLeaf: document.getElementById('out-leaf'),
101
+ evaluate: document.getElementById('btn-evaluate'),
102
+ paste: document.getElementById('btn-paste-path'),
103
+ };
104
+
105
+ let viewport = null;
106
+ let stylesheetText = '';
107
+
108
+ // ======================================================================
109
+ // Reading the payload
110
+ // ======================================================================
111
+
112
+ function readPayload() {
113
+ const holder = document.getElementById('tree-data');
114
+ if (!holder) return null;
115
+ try {
116
+ return JSON.parse(holder.textContent);
117
+ } catch (error) {
118
+ console.error('tree payload could not be parsed', error);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Walk the card model once, collecting the records by id and the links that
125
+ * are NOT child edges: a reference to an already-drawn node, and a
126
+ * breakpoint's virtual parent. Both are drawn as extra edges afterwards,
127
+ * because the layout must place each block exactly once.
128
+ */
129
+ function indexTree(data) {
130
+ const byId = new Map();
131
+ const refLinks = [];
132
+
133
+ function walk(record) {
134
+ if (!record || record.is_reference) return;
135
+ byId.set(record.id, record);
136
+ (record._children || []).forEach((arm) => {
137
+ const child = arm.node;
138
+ if (child && child.is_reference) {
139
+ refLinks.push({ source: record.id, target: child.id, kind: 'reference', condition: arm.condition });
140
+ } else if (child) {
141
+ walk(child);
142
+ }
143
+ });
144
+ }
145
+
146
+ walk(data.root);
147
+ (data.virtual_links || []).forEach((link) => {
148
+ refLinks.push({ source: link.source, target: link.target, kind: 'virtual' });
149
+ });
150
+
151
+ return { byId, refLinks };
152
+ }
153
+
154
+ // ======================================================================
155
+ // Layout
156
+ // ======================================================================
157
+
158
+ /** The tree the layout places: real children only, honouring collapses. */
159
+ function buildLayoutNode(id) {
160
+ const record = state.byId.get(id);
161
+ const node = { id, record, children: [] };
162
+ if (state.collapsed.has(id)) return node;
163
+ (record._children || []).forEach((arm) => {
164
+ if (arm.node && !arm.node.is_reference) {
165
+ const child = buildLayoutNode(arm.node.id);
166
+ child.condition = arm.condition;
167
+ node.children.push(child);
168
+ }
169
+ });
170
+ return node;
171
+ }
172
+
173
+ /**
174
+ * Place every node on the grid.
175
+ *
176
+ * The tree layout gives each node a breadth (its order among its siblings)
177
+ * and a depth; which screen axis each becomes is the whole of the
178
+ * orientation switch.
179
+ */
180
+ function layout() {
181
+ const rootData = buildLayoutNode(state.data.root.id);
182
+ const hierarchy = d3.hierarchy(rootData, (d) => (d.children.length ? d.children : null));
183
+ // Unit spacing: what the layout hands back is then an INDEX - a place in
184
+ // the sibling order and a depth - and the cell sizes below are applied
185
+ // per orientation. Letting the layout space the nodes instead would fix
186
+ // one orientation's spacing and shear the other's.
187
+ const tree = d3.tree().nodeSize([1, 1]);
188
+ tree(hierarchy);
189
+
190
+ const horizontal = state.orientation === 'top-down';
191
+ const placed = [];
192
+ hierarchy.each((d) => {
193
+ const breadth = d.x;
194
+ const depth = d.y;
195
+ const cx = (horizontal ? breadth : depth) * cellX();
196
+ const cy = (horizontal ? depth : breadth) * cellY();
197
+ d.card = { x: cx - cardW() / 2, y: cy - cardH() / 2 };
198
+ placed.push(d);
199
+ });
200
+ return { hierarchy, placed, horizontal };
201
+ }
202
+
203
+ // ======================================================================
204
+ // Drawing
205
+ // ======================================================================
206
+
207
+ function svgEl(name, attrs) {
208
+ const node = document.createElementNS(SVG_NS, name);
209
+ for (const key in attrs) node.setAttribute(key, attrs[key]);
210
+ return node;
211
+ }
212
+
213
+ /**
214
+ * Break a display text to fit the card: `perLine` characters, `maxLines` lines.
215
+ *
216
+ * Both come from the card's current size rather than from a constant, so
217
+ * widening a card lets a long expression onto one line and heightening one
218
+ * lets it onto the next line instead of being dropped.
219
+ */
220
+ function wrapText(text, perLine, maxLines) {
221
+ const source = String(text == null ? '' : text);
222
+ if (source.length <= perLine) return [source];
223
+
224
+ const lines = [];
225
+ let rest = source;
226
+ while (rest.length && lines.length < maxLines) {
227
+ if (rest.length <= perLine) {
228
+ lines.push(rest);
229
+ rest = '';
230
+ break;
231
+ }
232
+ let cut = rest.lastIndexOf(' ', perLine);
233
+ // No space to break at: cut mid-token rather than overflow the card.
234
+ if (cut < perLine * 0.4) cut = perLine;
235
+ lines.push(rest.slice(0, cut));
236
+ rest = rest.slice(cut).replace(/^\s+/, '');
237
+ }
238
+ if (rest.length && lines.length) {
239
+ lines[lines.length - 1] = lines[lines.length - 1].slice(0, perLine - 1) + '…';
240
+ }
241
+ return lines;
242
+ }
243
+
244
+ /** An SVG text has no clipping, so a long line is cut to fit the card. */
245
+ function clamp(text, limit) {
246
+ const source = String(text);
247
+ return source.length <= limit ? source : source.slice(0, limit - 1) + '…';
248
+ }
249
+
250
+ function conditionClass(condition) {
251
+ if (!condition) return 'is-value';
252
+ if (condition.is_else) return 'is-else';
253
+ if (condition.value === true) return 'is-true';
254
+ if (condition.value === false) return 'is-false';
255
+ return 'is-value';
256
+ }
257
+
258
+ function conditionText(condition) {
259
+ if (!condition) return '';
260
+ if (condition.is_else) return 'else';
261
+ if (condition.is_none) return 'any';
262
+ if (condition.value === true) return 'true';
263
+ if (condition.value === false) return 'false';
264
+ return String(condition.text || '');
265
+ }
266
+
267
+ /** The point at t=0.5 of a cubic bezier: where an edge's chip sits. */
268
+ function bezierMid(p0, p1, p2, p3) {
269
+ return {
270
+ x: 0.125 * p0.x + 0.375 * p1.x + 0.375 * p2.x + 0.125 * p3.x,
271
+ y: 0.125 * p0.y + 0.375 * p1.y + 0.375 * p2.y + 0.125 * p3.y,
272
+ };
273
+ }
274
+
275
+ function curveOf(p0, p1, p2, p3) {
276
+ return `M${p0.x},${p0.y} C${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`;
277
+ }
278
+
279
+ /**
280
+ * A branch edge drawn between two card POSITIONS: the path, and where its
281
+ * condition chip goes.
282
+ *
283
+ * Positions are arguments rather than read off the cards, because the same
284
+ * call draws the link at its final place and at every step of a card's
285
+ * travel - a link is a path, so it does not move on its own; it is redrawn
286
+ * along wherever its two ends are at the time.
287
+ */
288
+ function edgePath(a, b, horizontal) {
289
+ let p0, p3;
290
+
291
+ if (horizontal) {
292
+ // Depth runs down the screen: leave the bottom, arrive at the top.
293
+ p0 = { x: a.x + cardW() / 2, y: a.y + cardH() };
294
+ p3 = { x: b.x + cardW() / 2, y: b.y };
295
+ } else {
296
+ // Depth runs across: leave the right side, arrive at the left.
297
+ p0 = { x: a.x + cardW(), y: a.y + cardH() / 2 };
298
+ p3 = { x: b.x, y: b.y + cardH() / 2 };
299
+ }
300
+
301
+ const span = horizontal ? p3.y - p0.y : p3.x - p0.x;
302
+ const lift = Math.max(18, Math.abs(span) * 0.42);
303
+ const p1 = horizontal ? { x: p0.x, y: p0.y + lift } : { x: p0.x + lift, y: p0.y };
304
+ const p2 = horizontal ? { x: p3.x, y: p3.y - lift } : { x: p3.x - lift, y: p3.y };
305
+
306
+ return { d: curveOf(p0, p1, p2, p3), mid: bezierMid(p0, p1, p2, p3) };
307
+ }
308
+
309
+ /**
310
+ * A breakpoint's link to what it resumed into: dashed, and with no chip.
311
+ *
312
+ * Source's far side to the target's near side, the same way a child edge
313
+ * runs - the reference leaves the node it hangs off the way a branch does.
314
+ */
315
+ function referencePath(a, b, horizontal) {
316
+ const p0 = horizontal
317
+ ? { x: a.x + cardW() / 2, y: a.y + cardH() }
318
+ : { x: a.x + cardW(), y: a.y + cardH() / 2 };
319
+ const p3 = horizontal
320
+ ? { x: b.x + cardW() / 2, y: b.y }
321
+ : { x: b.x, y: b.y + cardH() / 2 };
322
+ const d = horizontal
323
+ ? `M${p0.x},${p0.y} C${p0.x},${(p0.y + p3.y) / 2} ${p3.x},${(p0.y + p3.y) / 2} ${p3.x},${p3.y}`
324
+ : `M${p0.x},${p0.y} C${(p0.x + p3.x) / 2},${p0.y} ${(p0.x + p3.x) / 2},${p3.y} ${p3.x},${p3.y}`;
325
+ return { d, mid: null };
326
+ }
327
+
328
+ function pathFor(entry, a, b) {
329
+ return entry.virtual ? referencePath(a, b, entry.horizontal) : edgePath(a, b, entry.horizontal);
330
+ }
331
+
332
+ /** Where a link's condition chip sits, given where its curve now runs. */
333
+ function placeChip(entry, mid) {
334
+ if (!entry.chip || !mid) return;
335
+ entry.chip.background.setAttribute('x', mid.x - entry.chip.width / 2);
336
+ entry.chip.background.setAttribute('y', mid.y - 8);
337
+ entry.chip.label.setAttribute('x', mid.x);
338
+ entry.chip.label.setAttribute('y', mid.y + 1);
339
+ }
340
+
341
+ function drawCard(parentGroup, placed) {
342
+ const rec = placed.data.record;
343
+ const W = cardW();
344
+ const H = cardH();
345
+ const group = svgEl('g', {
346
+ // The TYPE as well as the family: a family says where a node sits,
347
+ // the type says what it does, and for the action family that is the
348
+ // whole of what a reader wants off the card.
349
+ class: `card fam-${rec.family || 'OTHER'} type-${rec.type || 'UNKNOWN'}`,
350
+ transform: `translate(${placed.card.x},${placed.card.y})`,
351
+ });
352
+ group.dataset.id = rec.id;
353
+
354
+ group.appendChild(svgEl('rect', {
355
+ class: 'card-body', x: 0, y: 0, width: W, height: H,
356
+ rx: CORNER, ry: CORNER,
357
+ }));
358
+
359
+ // The header band: a rounded rect with its lower corners squared off by
360
+ // the path itself, which is cheaper than a clip path per card.
361
+ group.appendChild(svgEl('path', {
362
+ class: 'card-band',
363
+ d: `M0,${BAND_H} V${CORNER} Q0,0 ${CORNER},0 H${W - CORNER} Q${W},0 ${W},${CORNER} V${BAND_H} Z`,
364
+ }));
365
+
366
+ const typeText = svgEl('text', { class: 'card-type', x: CARD_PAD, y: BAND_H / 2 + 1 });
367
+ typeText.textContent = rec.type || '?';
368
+ group.appendChild(typeText);
369
+
370
+ const badges = [];
371
+ if (rec.autogen) badges.push('autogen');
372
+ if (rec.await_connection) badges.push('waiting');
373
+ if (rec.operands && rec.operands.length) badges.push(`${rec.operands.length} ops`);
374
+ if (rec.hooks && rec.hooks.length) badges.push(rec.hooks.join(','));
375
+ if (badges.length) {
376
+ const badge = svgEl('text', { class: 'card-badge-text', x: W - CARD_PAD, y: BAND_H / 2 + 1 });
377
+ // The badges share the band with the type, so they get what is left
378
+ // of it rather than a width of their own.
379
+ const room = Math.max(6, Math.floor((W - CARD_PAD * 3 - typeText.textContent.length * 7) / CHAR_W));
380
+ badge.textContent = clamp(badges.join(' · '), room);
381
+ group.appendChild(badge);
382
+ }
383
+
384
+ // Display text, wrapped to the card's width and centred in the space
385
+ // the band and the metadata strip leave.
386
+ const lines = wrapText(rec.repr, charsPerLine(), reprLines());
387
+ const top = BAND_H;
388
+ const bottom = H - META_ROW_H;
389
+ const block = lines.length * REPR_LINE_H;
390
+ const first = top + Math.max(0, (bottom - top - block) / 2) + REPR_LINE_H * 0.7;
391
+ lines.forEach((line, index) => {
392
+ const text = svgEl('text', { class: 'card-repr', x: CARD_PAD, y: first + index * REPR_LINE_H });
393
+ text.textContent = line;
394
+ group.appendChild(text);
395
+ });
396
+
397
+ // The metadata line: what group the node belongs to, what it holds
398
+ // itself, what it last produced, and how much hangs below it.
399
+ const meta = [];
400
+ if (rec.labels && rec.labels.length) meta.push(rec.labels.join(','));
401
+ // What the node holds ITSELF comes before what it produced: a literal's
402
+ // value and a read's entry are the node's meaning, the output is a
403
+ // consequence of it.
404
+ if (rec.self_value !== null && rec.self_value !== undefined) meta.push(`=${rec.self_value}`);
405
+ if (rec.out !== null && rec.out !== undefined) meta.push(`out=${rec.out}`);
406
+ if (rec.size > 1) meta.push(`${rec.size} nodes`);
407
+ if (meta.length) {
408
+ const metaText = svgEl('text', { class: 'card-meta', x: CARD_PAD, y: H - 12 });
409
+ metaText.textContent = clamp(meta.join(' · '), charsPerLine() + 4);
410
+ group.appendChild(metaText);
411
+ }
412
+
413
+ const armCount = (rec._children || []).filter((arm) => arm.node && !arm.node.is_reference).length;
414
+ if (armCount) {
415
+ const collapsed = state.collapsed.has(rec.id);
416
+ const toggle = svgEl('g', { class: 'card-toggle' });
417
+ toggle.appendChild(svgEl('rect', {
418
+ class: 'card-toggle-bg', x: W - 30, y: H - 26, width: 22, height: 18, rx: 5, ry: 5,
419
+ }));
420
+ const label = svgEl('text', { class: 'card-toggle-text', x: W - 19, y: H - 16 });
421
+ label.textContent = collapsed ? `+${armCount}` : '−';
422
+ toggle.appendChild(label);
423
+ toggle.addEventListener('click', (event) => {
424
+ event.stopPropagation();
425
+ if (state.collapsed.has(rec.id)) state.collapsed.delete(rec.id);
426
+ else state.collapsed.add(rec.id);
427
+ render({ animate: true });
428
+ });
429
+ group.appendChild(toggle);
430
+ }
431
+
432
+ group.addEventListener('click', (event) => {
433
+ event.stopPropagation();
434
+ select(rec.id);
435
+ });
436
+
437
+ parentGroup.appendChild(group);
438
+ state.cards.set(rec.id, group);
439
+ }
440
+
441
+ /**
442
+ * Draw one link, and remember everything needed to draw it again.
443
+ *
444
+ * The key is what tells a link in this drawing from the same link in the
445
+ * last one - an edge is made afresh on every render, so without it there is
446
+ * no way to know which have just appeared and which were there all along.
447
+ */
448
+ function drawLink(parentGroup, source, target, horizontal, options) {
449
+ const entry = {
450
+ path: null,
451
+ source,
452
+ target,
453
+ horizontal,
454
+ virtual: Boolean(options.virtual),
455
+ key: options.key || `${source.data.record.id}>${target.data.record.id}`,
456
+ chip: null,
457
+ };
458
+
459
+ const drawn = pathFor(entry, source.card, target.card);
460
+ entry.path = svgEl('path', { class: entry.virtual ? 'vlink' : 'edge', d: drawn.d });
461
+ parentGroup.appendChild(entry.path);
462
+
463
+ if (options.condition) {
464
+ const text = conditionText(options.condition);
465
+ const width = Math.max(30, text.length * 6.2 + 14);
466
+ const chip = svgEl('g', { class: `edge-chip ${conditionClass(options.condition)}` });
467
+ const background = svgEl('rect', { class: 'edge-chip-bg', width, height: 16, rx: 8, ry: 8 });
468
+ const label = svgEl('text', { class: 'edge-chip-text' });
469
+ label.textContent = text;
470
+ chip.appendChild(background);
471
+ chip.appendChild(label);
472
+ parentGroup.appendChild(chip);
473
+ entry.chip = { group: chip, background, label, width };
474
+ placeChip(entry, drawn.mid);
475
+ }
476
+
477
+ state.edges.push(entry);
478
+ return entry;
479
+ }
480
+
481
+ // ======================================================================
482
+ // Render
483
+ // ======================================================================
484
+
485
+ /** The duration of the next layout animation, from the speed slider. */
486
+ function animationMs() {
487
+ return ANIM_BASE_MS * (100 / Math.max(1, presentation.animSpeed));
488
+ }
489
+
490
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
491
+
492
+ /**
493
+ * What an element settles at once the animation lets go of it.
494
+ *
495
+ * Read BEFORE the animation writes an opacity of its own, and read off the
496
+ * stylesheet rather than assumed to be 1: a card arriving into a dimmed
497
+ * graph is muted by its class, and an inline opacity that ran to 1 would
498
+ * override that for the length of the fade - so the muting would land
499
+ * afterwards, as a change of its own rather than as part of the arrival.
500
+ */
501
+ function restingOpacity(el) {
502
+ const value = Number(getComputedStyle(el).opacity);
503
+ return Number.isFinite(value) ? value : 1;
504
+ }
505
+
506
+ // Superseding token: a second change while one is running calls off the
507
+ // first, which would otherwise go on writing to nodes nobody can see.
508
+ let animationRun = 0;
509
+
510
+ /**
511
+ * Drive a value from 0 to 1 over `ms`, on the frame clock.
512
+ *
513
+ * The animation is written here rather than handed to CSS on purpose. A
514
+ * transition needs the browser to have committed a "before" value, and the
515
+ * nodes it would run on were made a moment earlier in the same turn - so
516
+ * whether the browser sees two states or one coalesced write is its choice,
517
+ * not ours, and the answer differs between them. Setting the value per
518
+ * frame is the same work and none of the guessing.
519
+ */
520
+ function runAnimation(frame, ms, done) {
521
+ const run = ++animationRun;
522
+ const started = performance.now();
523
+
524
+ const step = (now) => {
525
+ if (run !== animationRun) return; // called off: leave the nodes be
526
+ const travelled = Math.min(1, (now - started) / ms);
527
+ frame(easeOut(travelled));
528
+ if (travelled < 1) requestAnimationFrame(step);
529
+ else done();
530
+ };
531
+ requestAnimationFrame(step);
532
+ }
533
+
534
+ /**
535
+ * Carry what changed from where it was to where it now is.
536
+ *
537
+ * The layout is recomputed from scratch on every change, so everything that
538
+ * moved would jump. Three things have to be put right, and NOTHING else
539
+ * touched - an unaffected card, link or condition chip that is animated
540
+ * anyway reads as the whole drawing flashing:
541
+ *
542
+ * - a card that MOVED is set back where it was and travels;
543
+ * - a link joined to one is redrawn along wherever its two ends are;
544
+ * - whatever has LEFT - a card, or a link whose ends are gone - keeps a
545
+ * copy on screen to fade, because it is out of the drawing by then and
546
+ * nothing can animate it.
547
+ */
548
+ function animateLayout(context) {
549
+ const { previous, positions, previousCards, previousEdges, arrivingEdges } = context;
550
+ const ms = animationMs();
551
+ const stillHere = new Set(state.edges.map((entry) => entry.key));
552
+
553
+ const ghost = svgEl('g', { class: 'ghost-layer' });
554
+ previousCards.forEach((group, id) => {
555
+ if (positions.has(id)) return; // it survives, and travels instead
556
+ ghost.appendChild(group.cloneNode(true));
557
+ });
558
+ previousEdges.forEach((entry) => {
559
+ if (stillHere.has(entry.key)) return; // both its ends are still there
560
+ ghost.appendChild(entry.path.cloneNode(true));
561
+ if (entry.chip) ghost.appendChild(entry.chip.group.cloneNode(true));
562
+ });
563
+ const leaving = ghost.childNodes.length > 0;
564
+ if (leaving) {
565
+ ghost.style.opacity = '1';
566
+ viewport.appendChild(ghost);
567
+ }
568
+
569
+ const moves = new Map();
570
+ const arrivingCards = [];
571
+ state.cards.forEach((group, id) => {
572
+ const was = previous.get(id);
573
+ const now = positions.get(id);
574
+ if (!now) return;
575
+ if (!was) {
576
+ arrivingCards.push({ el: group, to: restingOpacity(group) });
577
+ group.style.opacity = '0';
578
+ return;
579
+ }
580
+ if (was.x === now.x && was.y === now.y) return;
581
+ group.setAttribute('transform', `translate(${was.x},${was.y})`);
582
+ moves.set(id, { group, from: was, to: now });
583
+ });
584
+
585
+ const arrivingLinks = arrivingEdges.map((entry) => ({ el: entry.path, to: restingOpacity(entry.path) }));
586
+ arrivingLinks.forEach(({ el }) => { el.style.opacity = '0'; });
587
+
588
+ // A link is a path: it has no position of its own to travel from, so it
589
+ // is redrawn each frame along the two ends it joins - and only the ones
590
+ // that actually join a card on the move are redrawn at all.
591
+ const travelling = state.edges.filter((entry) =>
592
+ moves.has(entry.source.data.record.id) || moves.has(entry.target.data.record.id));
593
+
594
+ if (!moves.size && !arrivingCards.length && !arrivingLinks.length && !leaving) return;
595
+
596
+ const at = (entry, id, t) => {
597
+ const move = moves.get(id);
598
+ if (!move) return null;
599
+ return {
600
+ x: move.from.x + (move.to.x - move.from.x) * t,
601
+ y: move.from.y + (move.to.y - move.from.y) * t,
602
+ };
603
+ };
604
+
605
+ // The values the animation drives are also the ones the stylesheet
606
+ // fades, so the stylesheet is stood down for the length of it.
607
+ const fading = [...arrivingCards, ...arrivingLinks, ...(leaving ? [{ el: ghost }] : [])];
608
+ fading.forEach(({ el }) => { el.style.transition = 'none'; });
609
+
610
+ runAnimation((t) => {
611
+ moves.forEach((move) => {
612
+ move.group.setAttribute(
613
+ 'transform',
614
+ `translate(${move.from.x + (move.to.x - move.from.x) * t},${move.from.y + (move.to.y - move.from.y) * t})`,
615
+ );
616
+ });
617
+ travelling.forEach((entry) => {
618
+ const a = at(entry, entry.source.data.record.id, t) || entry.source.card;
619
+ const b = at(entry, entry.target.data.record.id, t) || entry.target.card;
620
+ const drawn = pathFor(entry, a, b);
621
+ entry.path.setAttribute('d', drawn.d);
622
+ placeChip(entry, drawn.mid);
623
+ });
624
+ // Each fades to where its own class says it belongs: an arriving
625
+ // card in a dimmed graph is muted from the moment it starts to
626
+ // appear, rather than brightening and then being muted.
627
+ arrivingCards.forEach(({ el, to }) => { el.style.opacity = String(to * t); });
628
+ arrivingLinks.forEach(({ el, to }) => { el.style.opacity = String(to * t); });
629
+ if (leaving) ghost.style.opacity = String(1 - t);
630
+ }, ms, () => {
631
+ // Hand every node back to its markup: an inline value left behind
632
+ // would fight the next render, and the position belongs to the
633
+ // transform attribute.
634
+ moves.forEach((move) => move.group.setAttribute('transform', `translate(${move.to.x},${move.to.y})`));
635
+ fading.forEach(({ el }) => { el.style.transition = ''; el.style.opacity = ''; });
636
+ if (leaving) ghost.remove();
637
+ });
638
+ }
639
+
640
+ function render(options) {
641
+ const animate = Boolean(options && options.animate);
642
+ const previous = state.positions;
643
+ const previousCards = state.cards;
644
+ const previousEdges = state.edges;
645
+
646
+ state.cards = new Map();
647
+ state.edges = [];
648
+
649
+ const { hierarchy, placed, horizontal } = layout();
650
+ const edgesGroup = svgEl('g', { id: 'edges' });
651
+ const cardsGroup = svgEl('g', { id: 'cards' });
652
+
653
+ const byLayoutId = new Map();
654
+ placed.forEach((d) => byLayoutId.set(d.data.record.id, d));
655
+
656
+ hierarchy.each((d) => {
657
+ (d.children || []).forEach((child) => {
658
+ // `child.data` is the layout node; `child` is d3's wrapper.
659
+ drawLink(edgesGroup, d, child, horizontal, { condition: child.data.condition });
660
+ });
661
+ });
662
+
663
+ state.refLinks.forEach((link) => {
664
+ const source = byLayoutId.get(link.source);
665
+ const target = byLayoutId.get(link.target);
666
+ if (source && target) {
667
+ drawLink(edgesGroup, source, target, horizontal, {
668
+ virtual: true,
669
+ key: `ref:${link.source}>${link.target}`,
670
+ });
671
+ }
672
+ });
673
+
674
+ placed.forEach((d) => drawCard(cardsGroup, d));
675
+
676
+ viewport.textContent = '';
677
+ viewport.appendChild(edgesGroup);
678
+ viewport.appendChild(cardsGroup);
679
+
680
+ const positions = new Map();
681
+ placed.forEach((d) => positions.set(d.data.record.id, { x: d.card.x, y: d.card.y }));
682
+ state.positions = positions;
683
+
684
+ // Which links are new to this drawing. A link is made afresh on every
685
+ // render, so only the keys tell one that has just appeared from one that
686
+ // was there all along - and only the former has any business fading in.
687
+ const arrivingEdges = state.edges.filter((entry) => !state.edgeKeys.has(entry.key));
688
+ state.edgeKeys = new Set(state.edges.map((entry) => entry.key));
689
+
690
+ paint();
691
+
692
+ if (animate) {
693
+ animateLayout({ previous, positions, previousCards, previousEdges, arrivingEdges });
694
+ }
695
+ }
696
+
697
+ /** Is this node outside the store group the sidebar picked? */
698
+ function filteredOut(id) {
699
+ if (state.group === '*') return false;
700
+ const rec = state.byId.get(id);
701
+ return !rec || !(rec.labels || []).includes(state.group);
702
+ }
703
+
704
+ /** Apply the filters and the selection to what is already drawn. */
705
+ function paint() {
706
+ const walked = state.highlight && state.hasWalk;
707
+
708
+ const onPath = (id) => walked && state.activeIds.has(id);
709
+
710
+ state.cards.forEach((group, id) => {
711
+ group.classList.toggle('is-active', onPath(id));
712
+ group.classList.toggle('is-dim', filteredOut(id) || (walked && !onPath(id)));
713
+ group.classList.toggle('is-selected', state.selected === id);
714
+ group.classList.toggle('is-failed', state.failedId === id);
715
+ });
716
+
717
+ // A link follows what it joins: it dims when either end does, and lights
718
+ // when BOTH ends are on the walk - which is what makes the path read as
719
+ // one line rather than as a set of lit cards. The condition chip is part
720
+ // of the link, so it goes with it.
721
+ state.edges.forEach((entry) => {
722
+ const lit = onPath(entry.source) && onPath(entry.target);
723
+ const dim = filteredOut(entry.source) || filteredOut(entry.target) || (walked && !lit);
724
+ entry.path.classList.toggle('is-active', lit);
725
+ entry.path.classList.toggle('is-dim', dim);
726
+ if (entry.chip) {
727
+ entry.chip.group.classList.toggle('is-active', lit);
728
+ entry.chip.group.classList.toggle('is-dim', dim);
729
+ }
730
+ });
731
+ }
732
+
733
+ // ======================================================================
734
+ // Zoom and pan
735
+ // ======================================================================
736
+
737
+ function fit() {
738
+ if (!state.cards.size || !state.zoom) return;
739
+ const box = viewport.getBBox();
740
+ const stage = el.svg.getBoundingClientRect();
741
+ const pad = 48;
742
+ const scale = Math.min(
743
+ (stage.width - pad * 2) / Math.max(box.width, 1),
744
+ (stage.height - pad * 2) / Math.max(box.height, 1),
745
+ 1.4,
746
+ );
747
+ const tx = stage.width / 2 - (box.x + box.width / 2) * scale;
748
+ const ty = stage.height / 2 - (box.y + box.height / 2) * scale;
749
+ // A selection, not the element: the zoom behaviour works on d3's
750
+ // wrapper, and handing it a bare DOM node fails inside d3.
751
+ d3.select(el.svg).call(state.zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
752
+ }
753
+
754
+ function zoomBy(factor) {
755
+ d3.select(el.svg).call(state.zoom.scaleBy, factor);
756
+ }
757
+
758
+ function setupZoom() {
759
+ state.zoom = d3.zoom()
760
+ .scaleExtent([ZOOM_MIN, ZOOM_MAX])
761
+ .on('zoom', (event) => {
762
+ viewport.setAttribute('transform', event.transform.toString());
763
+ });
764
+
765
+ d3.select(el.svg).call(state.zoom);
766
+
767
+ // A drag on a card is a drag on the canvas: panning is the gesture
768
+ // people reach for, and nothing here is drag-to-move.
769
+ d3.select(el.svg).on('dblclick.zoom', null);
770
+ }
771
+
772
+ // ======================================================================
773
+ // The panel on the right
774
+ // ======================================================================
775
+
776
+ const inspectorFields = {
777
+ 'ins-type': (rec) => rec.type,
778
+ 'ins-repr': (rec) => rec.repr,
779
+ 'ins-id': (rec) => rec.id,
780
+ 'ins-address': (rec) => `0x${Number(rec.address).toString(16)}`,
781
+ 'ins-family': (rec) => rec.family,
782
+ 'ins-labels': (rec) => (rec.labels && rec.labels.length ? rec.labels.join(', ') : '—'),
783
+ 'ins-out': (rec) => (rec.out === null || rec.out === undefined ? '—' : rec.out),
784
+ 'ins-self': (rec) => (rec.self_value === null || rec.self_value === undefined ? '—' : rec.self_value),
785
+ 'ins-hooks': (rec) => (rec.hooks && rec.hooks.length ? rec.hooks.join(', ') : '—'),
786
+ 'ins-size': (rec) => `${rec.size} node(s)`,
787
+ 'ins-autogen': (rec) => (rec.autogen ? 'yes' : 'no'),
788
+ };
789
+
790
+ function select(id) {
791
+ const rec = state.byId.get(id);
792
+ if (!rec) return;
793
+ state.selected = id;
794
+
795
+ for (const field in inspectorFields) {
796
+ const target = document.getElementById(field);
797
+ const value = inspectorFields[field](rec);
798
+ target.textContent = value === null || value === undefined ? '—' : String(value);
799
+ }
800
+
801
+ el.arms.textContent = '';
802
+ (rec._children || []).forEach((arm) => {
803
+ const item = document.createElement('li');
804
+ const key = document.createElement('span');
805
+ key.className = `arm-key ${conditionClass(arm.condition)}`;
806
+ key.textContent = conditionText(arm.condition);
807
+ const name = document.createElement('span');
808
+ name.className = 'arm-name';
809
+ name.textContent = arm.node && arm.node.is_reference
810
+ ? `${arm.node.id} (drawn elsewhere)`
811
+ : `${arm.node ? arm.node.type : '?'} — ${arm.node ? arm.node.repr : ''}`;
812
+ item.appendChild(key);
813
+ item.appendChild(name);
814
+ el.arms.appendChild(item);
815
+ });
816
+
817
+ // The operands: nodes this one reads, which are NOT its children and
818
+ // so are not the cards below it.
819
+ const operands = rec.operands || [];
820
+ el.operands.textContent = '';
821
+ if (!operands.length) {
822
+ const item = document.createElement('li');
823
+ const name = document.createElement('span');
824
+ name.className = 'arm-name is-own';
825
+ name.textContent = 'none — this node has no operands';
826
+ item.appendChild(name);
827
+ el.operands.appendChild(item);
828
+ }
829
+ operands.forEach((operand, index) => {
830
+ const item = document.createElement('li');
831
+ const key = document.createElement('span');
832
+ key.className = 'arm-key';
833
+ key.textContent = String(index);
834
+ const name = document.createElement('span');
835
+ name.className = 'arm-name';
836
+ name.textContent = `${operand.type} — ${operand.repr}`;
837
+ item.appendChild(key);
838
+ item.appendChild(name);
839
+ el.operands.appendChild(item);
840
+ });
841
+
842
+ el.inspector.hidden = false;
843
+ paint();
844
+ }
845
+
846
+ // ======================================================================
847
+ // Walk state
848
+ // ======================================================================
849
+
850
+ function applyWalk(payload) {
851
+ if (!payload) return;
852
+ state.walk = payload;
853
+ state.activeIds = new Set(payload.active_ids || []);
854
+ state.hasWalk = state.activeIds.size > 0;
855
+ state.failedId = payload.failed || null;
856
+
857
+ el.outCode.textContent = payload.code || '—';
858
+ el.outLength.textContent = payload.length === undefined ? state.activeIds.size : String(payload.length);
859
+ const leaf = payload.leaf ? state.byId.get(payload.leaf) : null;
860
+ el.outLeaf.textContent = leaf ? `${leaf.type} — ${leaf.repr}` : '—';
861
+ el.statWalked.textContent = String(state.activeIds.size);
862
+ paint();
863
+ }
864
+
865
+ async function evaluate() {
866
+ if (window.bake_viewer.offline) {
867
+ setHint('An exported page cannot walk the graph — it has no process behind it.', true);
868
+ return;
869
+ }
870
+ el.evaluate.disabled = true;
871
+ try {
872
+ const response = await fetch('/api/evaluate', { method: 'POST' });
873
+ const payload = await response.json();
874
+ applyWalk(payload);
875
+ if (payload.error) setHint(`Walk failed: ${payload.error}`, true);
876
+ else setHint('');
877
+ } catch (error) {
878
+ setHint(`Walk failed: ${error.message}`, true);
879
+ } finally {
880
+ el.evaluate.disabled = false;
881
+ }
882
+ }
883
+
884
+ function watchStream() {
885
+ if (window.bake_viewer.offline || !window.bake_viewer.with_watch) return;
886
+ const source = new EventSource('/watch');
887
+ source.onmessage = (event) => {
888
+ try {
889
+ const diff = JSON.parse(event.data);
890
+ (diff.removed || []).forEach((id) => state.activeIds.delete(id));
891
+ (diff.added || []).forEach((id) => state.activeIds.add(id));
892
+ state.hasWalk = state.activeIds.size > 0;
893
+ el.statWalked.textContent = String(state.activeIds.size);
894
+ paint();
895
+ } catch (error) {
896
+ console.error('watch payload could not be parsed', error);
897
+ }
898
+ };
899
+ }
900
+
901
+ // ======================================================================
902
+ // Clipboard
903
+ // ======================================================================
904
+
905
+ function setHint(text, isError) {
906
+ el.clipboardHint.textContent = text || '';
907
+ el.clipboardHint.classList.toggle('is-error', Boolean(isError));
908
+ el.clipboardHint.classList.toggle('is-ok', Boolean(text) && !isError);
909
+ }
910
+
911
+ async function copyPath() {
912
+ const ids = Array.from(state.activeIds);
913
+ if (!ids.length) {
914
+ setHint('Nothing has been walked yet — press Evaluate first.', true);
915
+ return;
916
+ }
917
+ const payload = JSON.stringify(ids);
918
+ el.pathBox.value = payload;
919
+ try {
920
+ await navigator.clipboard.writeText(payload);
921
+ setHint(`Copied ${ids.length} node id(s).`);
922
+ } catch (error) {
923
+ setHint('Clipboard is not available — the ids are in the box, copy them from there.', true);
924
+ }
925
+ }
926
+
927
+ async function pastePath() {
928
+ try {
929
+ el.pathBox.value = await navigator.clipboard.readText();
930
+ setHint('Read the clipboard. Press Load to highlight it.');
931
+ } catch (error) {
932
+ setHint('Clipboard is not available — paste into the box by hand.', true);
933
+ }
934
+ }
935
+
936
+ function loadPath() {
937
+ let ids;
938
+ try {
939
+ ids = JSON.parse(el.pathBox.value);
940
+ } catch (error) {
941
+ setHint('That is not JSON. Expected a list of node ids.', true);
942
+ return;
943
+ }
944
+ if (!Array.isArray(ids)) {
945
+ setHint('Expected a list of node ids.', true);
946
+ return;
947
+ }
948
+ const known = ids.filter((id) => state.byId.has(id));
949
+ state.activeIds = new Set(known);
950
+ state.hasWalk = known.length > 0;
951
+ state.failedId = null;
952
+ el.statWalked.textContent = String(known.length);
953
+ el.outCode.textContent = 'loaded';
954
+ el.outLength.textContent = String(known.length);
955
+ el.outLeaf.textContent = '—';
956
+ paint();
957
+ setHint(known.length === ids.length
958
+ ? `Loaded ${known.length} node id(s).`
959
+ : `Loaded ${known.length} of ${ids.length} id(s) — the rest are not in this graph.`,
960
+ known.length !== ids.length);
961
+ }
962
+
963
+ function clearPath() {
964
+ state.activeIds = new Set();
965
+ state.hasWalk = false;
966
+ state.failedId = null;
967
+ el.pathBox.value = '';
968
+ el.statWalked.textContent = '0';
969
+ el.outCode.textContent = '—';
970
+ el.outLength.textContent = '—';
971
+ el.outLeaf.textContent = '—';
972
+ paint();
973
+ setHint('');
974
+ }
975
+
976
+ // ======================================================================
977
+ // Export
978
+ // ======================================================================
979
+
980
+ const THEME_VARS = [
981
+ '--bg', '--bg-grid', '--grid-line', '--panel', '--panel-2', '--item', '--line',
982
+ '--text', '--text-dim', '--text-faint', '--accent', '--accent-soft',
983
+ '--ok', '--warn', '--bad',
984
+ '--card-bg', '--card-line', '--card-repr', '--card-meta', '--card-active-ring',
985
+ '--fam-input', '--fam-op', '--fam-action', '--fam-special', '--fam-other', '--fam-on',
986
+ '--act-long', '--act-short', '--act-none', '--act-cancel', '--act-clear', '--act-placeholder',
987
+ '--edge-width',
988
+ '--font-ui', '--font-mono',
989
+ ];
990
+
991
+ function exportableSvg() {
992
+ if (!state.cards.size) {
993
+ alert('Nothing to export.');
994
+ return null;
995
+ }
996
+ const box = viewport.getBBox();
997
+ const pad = 32;
998
+ const clone = el.svg.cloneNode(true);
999
+
1000
+ clone.setAttribute('xmlns', SVG_NS);
1001
+ clone.setAttribute('viewBox', `${box.x - pad} ${box.y - pad} ${box.width + pad * 2} ${box.height + pad * 2}`);
1002
+ clone.setAttribute('width', Math.ceil(box.width + pad * 2));
1003
+ clone.setAttribute('height', Math.ceil(box.height + pad * 2));
1004
+
1005
+ // The clone carries no stylesheet, so the theme values it is drawn with
1006
+ // are written into it - the same names the stylesheet reads.
1007
+ const computed = getComputedStyle(document.documentElement);
1008
+ let declarations = ':root{';
1009
+ THEME_VARS.forEach((name) => {
1010
+ const value = computed.getPropertyValue(name).trim();
1011
+ if (value) declarations += `${name}:${value};`;
1012
+ });
1013
+ declarations += `}\n#graph{background:${computed.getPropertyValue('--bg-grid').trim()};}`;
1014
+
1015
+ if (!stylesheetText) stylesheetText = readStylesheet();
1016
+ const style = document.createElementNS(SVG_NS, 'style');
1017
+ style.textContent = declarations + stylesheetText;
1018
+ clone.insertBefore(style, clone.firstChild);
1019
+
1020
+ // The viewport transform is dropped: the exported viewBox is the whole
1021
+ // graph, so whatever the screen was panned to is not the picture.
1022
+ const view = clone.querySelector('g');
1023
+ if (view) view.removeAttribute('transform');
1024
+
1025
+ return { svg: clone, width: box.width + pad * 2, height: box.height + pad * 2 };
1026
+ }
1027
+
1028
+ function downloadBlob(blob, filename) {
1029
+ const url = URL.createObjectURL(blob);
1030
+ const anchor = document.createElement('a');
1031
+ anchor.href = url;
1032
+ anchor.download = filename;
1033
+ document.body.appendChild(anchor);
1034
+ anchor.click();
1035
+ anchor.remove();
1036
+ setTimeout(() => URL.revokeObjectURL(url), 100);
1037
+ }
1038
+
1039
+ function exportSVG() {
1040
+ const drawn = exportableSvg();
1041
+ if (!drawn) return;
1042
+ const text = new XMLSerializer().serializeToString(drawn.svg);
1043
+ downloadBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), 'bake-graph.svg');
1044
+ }
1045
+
1046
+ function exportPNG() {
1047
+ const drawn = exportableSvg();
1048
+ if (!drawn) return;
1049
+ const scale = 2;
1050
+ const text = new XMLSerializer().serializeToString(drawn.svg);
1051
+ const image = new Image();
1052
+ const url = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }));
1053
+
1054
+ image.onload = () => {
1055
+ const canvas = document.createElement('canvas');
1056
+ canvas.width = Math.ceil(drawn.width * scale);
1057
+ canvas.height = Math.ceil(drawn.height * scale);
1058
+ const context = canvas.getContext('2d');
1059
+ context.scale(scale, scale);
1060
+ context.drawImage(image, 0, 0);
1061
+ URL.revokeObjectURL(url);
1062
+ canvas.toBlob((blob) => downloadBlob(blob, 'bake-graph.png'), 'image/png');
1063
+ };
1064
+ image.onerror = () => {
1065
+ URL.revokeObjectURL(url);
1066
+ alert('The PNG export could not rasterize the drawing. The SVG export still works.');
1067
+ };
1068
+ image.src = url;
1069
+ }
1070
+
1071
+ // ======================================================================
1072
+ // Controls
1073
+ // ======================================================================
1074
+
1075
+ function setOrientation(orientation) {
1076
+ state.orientation = orientation;
1077
+ document.documentElement.dataset.orientation = orientation;
1078
+ document.querySelectorAll('#ctl-orientation button').forEach((button) => {
1079
+ button.classList.toggle('active', button.dataset.orientation === orientation);
1080
+ });
1081
+ render();
1082
+ fit();
1083
+ }
1084
+
1085
+ function setTheme(theme) {
1086
+ state.theme = theme;
1087
+ document.documentElement.dataset.theme = theme;
1088
+ document.querySelectorAll('#ctl-theme button').forEach((button) => {
1089
+ button.classList.toggle('active', button.dataset.theme === theme);
1090
+ });
1091
+ }
1092
+
1093
+ /** Paint the segmented controls from the state, without re-drawing. */
1094
+ function syncControls() {
1095
+ document.querySelectorAll('#ctl-orientation button').forEach((button) => {
1096
+ button.classList.toggle('active', button.dataset.orientation === state.orientation);
1097
+ });
1098
+ document.querySelectorAll('#ctl-theme button').forEach((button) => {
1099
+ button.classList.toggle('active', button.dataset.theme === state.theme);
1100
+ });
1101
+ }
1102
+
1103
+ function setGroup(group) {
1104
+ state.group = group;
1105
+ document.querySelectorAll('#ctl-groups .chip').forEach((chip) => {
1106
+ chip.classList.toggle('active', chip.dataset.group === group);
1107
+ });
1108
+ paint();
1109
+ }
1110
+
1111
+ function buildGroupChips() {
1112
+ const groups = new Set();
1113
+ state.byId.forEach((rec) => (rec.labels || []).forEach((label) => groups.add(label)));
1114
+ const sorted = Array.from(groups).sort();
1115
+
1116
+ el.groups.textContent = '';
1117
+ const make = (name, text) => {
1118
+ const chip = document.createElement('button');
1119
+ chip.type = 'button';
1120
+ chip.className = `chip${name === '*' ? ' active' : ''}`;
1121
+ chip.dataset.group = name;
1122
+ chip.textContent = text;
1123
+ chip.addEventListener('click', () => setGroup(name));
1124
+ el.groups.appendChild(chip);
1125
+ };
1126
+ make('*', 'All');
1127
+ sorted.forEach((name) => make(name, name));
1128
+ return sorted.length;
1129
+ }
1130
+
1131
+ // ---- The presentation sliders: what each drives, and how it reads. ----
1132
+ // A slider's units are its own (percent for a card, tenths for a stroke),
1133
+ // so each one says how to translate in and out of what it drives.
1134
+ const SLIDERS = {
1135
+ 'pres-level': {
1136
+ key: 'levelGap', out: 'pres-level-out',
1137
+ fromSlider: Number, toSlider: (v) => Math.round(v), format: (v) => String(Math.round(v)),
1138
+ },
1139
+ 'pres-sibling': {
1140
+ key: 'siblingGap', out: 'pres-sibling-out',
1141
+ fromSlider: Number, toSlider: (v) => Math.round(v), format: (v) => String(Math.round(v)),
1142
+ },
1143
+ 'pres-width': {
1144
+ key: 'cardWidth', out: 'pres-width-out',
1145
+ fromSlider: Number, toSlider: (v) => Math.round(v), format: (v) => String(Math.round(v)),
1146
+ },
1147
+ 'pres-height': {
1148
+ key: 'cardHeight', out: 'pres-height-out',
1149
+ fromSlider: Number, toSlider: (v) => Math.round(v), format: (v) => String(Math.round(v)),
1150
+ },
1151
+ 'pres-edge': {
1152
+ key: 'edgeWidth', out: 'pres-edge-out',
1153
+ fromSlider: (raw) => raw / 10, toSlider: (v) => Math.round(v * 10),
1154
+ format: (v) => v.toFixed(1),
1155
+ },
1156
+ 'pres-anim': {
1157
+ key: 'animSpeed', out: 'pres-anim-out',
1158
+ fromSlider: Number, toSlider: (v) => Math.round(v),
1159
+ format: (v) => `×${(v / 100).toFixed(2)}`,
1160
+ // The speed changes nothing about the drawing, so changing it does
1161
+ // not redraw one.
1162
+ redraws: false,
1163
+ },
1164
+ };
1165
+
1166
+ let presentationFrame = 0;
1167
+
1168
+ function syncSlider(id) {
1169
+ const spec = SLIDERS[id];
1170
+ const input = document.getElementById(id);
1171
+ if (!input) return;
1172
+ input.value = String(spec.toSlider(presentation[spec.key]));
1173
+ document.getElementById(spec.out).textContent = spec.format(presentation[spec.key]);
1174
+ }
1175
+
1176
+ function applyEdgeWidth() {
1177
+ document.documentElement.style.setProperty('--edge-width', String(presentation.edgeWidth));
1178
+ }
1179
+
1180
+ /**
1181
+ * Re-draw on the next frame, once.
1182
+ *
1183
+ * A drag fires an event per pixel and the drawing is rebuilt from scratch
1184
+ * every time, so the work is collapsed to one redraw per frame.
1185
+ */
1186
+ function scheduleRender() {
1187
+ if (presentationFrame) return;
1188
+ presentationFrame = requestAnimationFrame(() => {
1189
+ presentationFrame = 0;
1190
+ render();
1191
+ });
1192
+ }
1193
+
1194
+ function wirePresentation() {
1195
+ for (const id in SLIDERS) {
1196
+ const spec = SLIDERS[id];
1197
+ const input = document.getElementById(id);
1198
+ if (!input) continue;
1199
+ syncSlider(id);
1200
+ input.addEventListener('input', () => {
1201
+ presentation[spec.key] = spec.fromSlider(Number(input.value));
1202
+ document.getElementById(spec.out).textContent = spec.format(presentation[spec.key]);
1203
+ applyEdgeWidth();
1204
+ if (spec.redraws !== false) scheduleRender();
1205
+ });
1206
+ }
1207
+
1208
+ const reset = document.getElementById('btn-pres-reset');
1209
+ if (reset) {
1210
+ reset.addEventListener('click', () => {
1211
+ Object.assign(presentation, PRESENTATION_DEFAULTS);
1212
+ for (const id in SLIDERS) syncSlider(id);
1213
+ applyEdgeWidth();
1214
+ scheduleRender();
1215
+ });
1216
+ }
1217
+
1218
+ applyEdgeWidth();
1219
+ }
1220
+
1221
+ /**
1222
+ * Make every control panel foldable.
1223
+ *
1224
+ * The sidebar is a column of widgets, and the one a reader wants is rarely
1225
+ * the one taking the most room - so each panel keeps its title and puts its
1226
+ * body away. The body is wrapped here rather than in the markup, so a panel
1227
+ * is written once and a control added to it later is inside the fold by
1228
+ * default; the template says which panels start folded.
1229
+ */
1230
+ function buildPanels() {
1231
+ document.querySelectorAll('#sidebar .panel').forEach((panel) => {
1232
+ const title = panel.querySelector('.panel-title');
1233
+ if (!title || panel.dataset.foldable === 'ready') return;
1234
+ panel.dataset.foldable = 'ready';
1235
+
1236
+ const body = document.createElement('div');
1237
+ body.className = 'panel-body';
1238
+ while (title.nextSibling) body.appendChild(title.nextSibling);
1239
+ panel.appendChild(body);
1240
+
1241
+ const caret = document.createElement('span');
1242
+ caret.className = 'panel-caret';
1243
+ caret.setAttribute('aria-hidden', 'true');
1244
+ title.appendChild(caret);
1245
+
1246
+ title.classList.add('panel-toggle');
1247
+ title.setAttribute('role', 'button');
1248
+ title.setAttribute('tabindex', '0');
1249
+
1250
+ const setFolded = (folded) => {
1251
+ panel.classList.toggle('is-collapsed', folded);
1252
+ title.setAttribute('aria-expanded', String(!folded));
1253
+ };
1254
+ title.addEventListener('click', () => setFolded(!panel.classList.contains('is-collapsed')));
1255
+ title.addEventListener('keydown', (event) => {
1256
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1257
+ event.preventDefault();
1258
+ setFolded(!panel.classList.contains('is-collapsed'));
1259
+ });
1260
+
1261
+ setFolded(panel.dataset.collapsed === 'true');
1262
+ });
1263
+ }
1264
+
1265
+ function wire() {
1266
+ document.querySelectorAll('#ctl-orientation button').forEach((button) => {
1267
+ button.addEventListener('click', () => setOrientation(button.dataset.orientation));
1268
+ });
1269
+ document.querySelectorAll('#ctl-theme button').forEach((button) => {
1270
+ button.addEventListener('click', () => setTheme(button.dataset.theme));
1271
+ });
1272
+ document.getElementById('ctl-highlight').addEventListener('change', (event) => {
1273
+ state.highlight = event.target.checked;
1274
+ paint();
1275
+ });
1276
+ document.getElementById('btn-zoom-in').addEventListener('click', () => zoomBy(ZOOM_STEP));
1277
+ document.getElementById('btn-zoom-out').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
1278
+ document.getElementById('btn-zoom-reset').addEventListener('click', fit);
1279
+ document.getElementById('btn-copy-path').addEventListener('click', copyPath);
1280
+ document.getElementById('btn-paste-path').addEventListener('click', pastePath);
1281
+ document.getElementById('btn-load-path').addEventListener('click', loadPath);
1282
+ document.getElementById('btn-clear-path').addEventListener('click', clearPath);
1283
+ document.getElementById('btn-export-svg').addEventListener('click', exportSVG);
1284
+ document.getElementById('btn-export-png').addEventListener('click', exportPNG);
1285
+ el.evaluate.addEventListener('click', evaluate);
1286
+ document.getElementById('ins-close').addEventListener('click', () => {
1287
+ el.inspector.hidden = true;
1288
+ state.selected = null;
1289
+ paint();
1290
+ });
1291
+ document.addEventListener('keydown', (event) => {
1292
+ if (event.key === 'Escape') {
1293
+ el.inspector.hidden = true;
1294
+ state.selected = null;
1295
+ paint();
1296
+ }
1297
+ // Ctrl/Cmd + 0 fits the graph, the way every canvas app does.
1298
+ if ((event.ctrlKey || event.metaKey) && event.key === '0') {
1299
+ event.preventDefault();
1300
+ fit();
1301
+ }
1302
+ });
1303
+ // A click on the empty canvas clears the selection.
1304
+ el.svg.addEventListener('click', () => {
1305
+ if (state.selected === null) return;
1306
+ state.selected = null;
1307
+ el.inspector.hidden = true;
1308
+ paint();
1309
+ });
1310
+ }
1311
+
1312
+ // ======================================================================
1313
+ // Boot
1314
+ // ======================================================================
1315
+
1316
+ function boot() {
1317
+ const data = readPayload();
1318
+ if (!data || !data.root || !data.root.id) {
1319
+ showEmpty('Nothing to draw — the viewer is holding no graph.');
1320
+ return;
1321
+ }
1322
+ state.data = data;
1323
+
1324
+ const index = indexTree(data);
1325
+ state.byId = index.byId;
1326
+ state.refLinks = index.refLinks;
1327
+
1328
+ el.titleName.textContent = data.root_name || data.root.repr || data.root.id;
1329
+ el.statNodes.textContent = String(data.n_nodes || state.byId.size);
1330
+ // What is counted is what is DRAWN: a node reached only as an operand
1331
+ // is not a card, and the graph holds more of those than it shows.
1332
+ el.statNodes.parentElement.title =
1333
+ 'Cards drawn: a node reached only as an operand of another is not one of them.';
1334
+ showScaffolding(data.scaffolding);
1335
+
1336
+ buildPanels();
1337
+ buildGroupChips();
1338
+ wire();
1339
+ wirePresentation();
1340
+
1341
+ viewport = svgEl('g', { id: 'bake-viewport' });
1342
+ el.svg.appendChild(viewport);
1343
+ setupZoom();
1344
+ syncControls();
1345
+
1346
+ indexWalkFromPayload();
1347
+ render();
1348
+ fit();
1349
+ watchStream();
1350
+ }
1351
+
1352
+ /**
1353
+ * What the graph still carries of the BUILD.
1354
+ *
1355
+ * A breakpoint a split build stopped at and a placeholder a branch reserved
1356
+ * and never filled are both gone once the bake has run, so counting them is
1357
+ * what tells the two states of a graph apart - without having to have been
1358
+ * handed the report.
1359
+ */
1360
+ function showScaffolding(scaffolding) {
1361
+ const counts = scaffolding || { breakpoints: 0, placeholders: 0 };
1362
+ const parts = [];
1363
+ if (counts.breakpoints) parts.push(`${counts.breakpoints} breakpoint`);
1364
+ if (counts.placeholders) parts.push(`${counts.placeholders} placeholder`);
1365
+ if (parts.length) {
1366
+ el.statMode.textContent = `unbaked · ${parts.join(' · ')}`;
1367
+ el.statMode.title = 'The bake takes this down: what the scaffolding carried takes its place.';
1368
+ } else {
1369
+ el.statMode.textContent = 'no scaffolding';
1370
+ el.statMode.title = 'Neither a breakpoint nor a stand-in is left in this graph.';
1371
+ }
1372
+ }
1373
+
1374
+ /** The payload may already carry a walk (to_html with eval, or show()). */
1375
+ function indexWalkFromPayload() {
1376
+ if (!Array.isArray(state.data.active_ids) || !state.data.active_ids.length) return;
1377
+ state.activeIds = new Set(state.data.active_ids.filter((id) => state.byId.has(id)));
1378
+ state.hasWalk = state.activeIds.size > 0;
1379
+ state.walk = state.data.walk || null;
1380
+ if (state.walk) {
1381
+ state.failedId = state.walk.failed || null;
1382
+ el.outCode.textContent = state.walk.code || '—';
1383
+ el.outLength.textContent = String(state.walk.length === undefined ? state.activeIds.size : state.walk.length);
1384
+ const leaf = state.walk.leaf ? state.byId.get(state.walk.leaf) : null;
1385
+ el.outLeaf.textContent = leaf ? `${leaf.type} — ${leaf.repr}` : '—';
1386
+ }
1387
+ el.statWalked.textContent = String(state.activeIds.size);
1388
+ }
1389
+
1390
+ /**
1391
+ * The stylesheet text, read once so the export can carry it.
1392
+ *
1393
+ * Every readable sheet is collected rather than the one this page links:
1394
+ * an exported file has no <link> to follow, and the offline page has no
1395
+ * link at all - its stylesheet is already inline.
1396
+ */
1397
+ function readStylesheet() {
1398
+ let text = '';
1399
+ for (const sheet of document.styleSheets) {
1400
+ try {
1401
+ if (!sheet.cssRules) continue;
1402
+ for (const rule of sheet.cssRules) text += rule.cssText + '\n';
1403
+ } catch (error) {
1404
+ /* a cross-origin sheet: it cannot be read, and cannot be exported */
1405
+ }
1406
+ }
1407
+ return text;
1408
+ }
1409
+
1410
+ /**
1411
+ * Say what went wrong, in the middle of the stage.
1412
+ *
1413
+ * A page that draws nothing and says nothing is the worst of both: the
1414
+ * reader cannot tell an empty graph from a broken drawing.
1415
+ */
1416
+ function showEmpty(message) {
1417
+ el.emptyNote.textContent = message;
1418
+ el.emptyNote.hidden = false;
1419
+ el.emptyNote.classList.add('is-visible');
1420
+ }
1421
+
1422
+ window.addEventListener('error', (event) => {
1423
+ showEmpty(`The drawing failed: ${event.message}`);
1424
+ });
1425
+
1426
+ function start() {
1427
+ try {
1428
+ boot();
1429
+ } catch (error) {
1430
+ showEmpty(`The drawing failed: ${error && error.message ? error.message : error}`);
1431
+ console.error(error);
1432
+ }
1433
+ }
1434
+
1435
+ if (document.readyState === 'loading') {
1436
+ document.addEventListener('DOMContentLoaded', start);
1437
+ } else {
1438
+ start();
1439
+ }
1440
+ })();