@neoloopy/cld-canvas 0.1.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 (41) hide show
  1. package/dist/engine/analysis.d.ts +15 -0
  2. package/dist/engine/analysis.js +30 -0
  3. package/dist/engine/engine.d.ts +203 -0
  4. package/dist/engine/engine.js +7 -0
  5. package/dist/engine/exporters.d.ts +45 -0
  6. package/dist/engine/exporters.js +110 -0
  7. package/dist/engine/layout.d.ts +19 -0
  8. package/dist/engine/layout.js +142 -0
  9. package/dist/engine/loopGraph.d.ts +51 -0
  10. package/dist/engine/loopGraph.js +0 -0
  11. package/dist/engine/loopKey.d.ts +24 -0
  12. package/dist/engine/loopKey.js +32 -0
  13. package/dist/engine/loopNote.d.ts +40 -0
  14. package/dist/engine/loopNote.js +130 -0
  15. package/dist/engine/nativeEngine.d.ts +175 -0
  16. package/dist/engine/nativeEngine.js +1120 -0
  17. package/dist/engine/noteCodec.d.ts +29 -0
  18. package/dist/engine/noteCodec.js +254 -0
  19. package/dist/engine/noteNaming.d.ts +18 -0
  20. package/dist/engine/noteNaming.js +28 -0
  21. package/dist/engine/specHash.d.ts +37 -0
  22. package/dist/engine/specHash.js +86 -0
  23. package/dist/engine/storage.d.ts +49 -0
  24. package/dist/engine/storage.js +116 -0
  25. package/dist/engine/subsystemLinks.d.ts +29 -0
  26. package/dist/engine/subsystemLinks.js +55 -0
  27. package/dist/engine/types.d.ts +103 -0
  28. package/dist/engine/types.js +155 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.js +19 -0
  31. package/dist/view/camera.d.ts +42 -0
  32. package/dist/view/camera.js +75 -0
  33. package/dist/view/geometry.d.ts +83 -0
  34. package/dist/view/geometry.js +408 -0
  35. package/dist/view/painter.d.ts +63 -0
  36. package/dist/view/painter.js +471 -0
  37. package/dist/view/sceneCache.d.ts +61 -0
  38. package/dist/view/sceneCache.js +134 -0
  39. package/dist/view/theme.d.ts +57 -0
  40. package/dist/view/theme.js +90 -0
  41. package/package.json +17 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Canvas painter — renders a prepared scene (node boxes, curved edges, loop
3
+ * badges) with the app's visual language ported from
4
+ * `app/lib/painters/graph_painter.dart`. Pure: takes a 2D context + scene +
5
+ * camera + theme + interaction state and draws. Deferred flourishes (valence/CLA
6
+ * marks) are tracked in Status.md; the core CLD reading — node types, arc edges
7
+ * with polarity chips, R/B badges, the subsystem-link corner mark, and selection
8
+ * + live-edit highlight — is here. Per-node
9
+ * quantitative detail lives in the ƒx node-menu modal, not on the canvas.
10
+ */
11
+ import { groupSwatch, swatchBorder, swatchFill, swatchInk, withAlpha } from "./theme";
12
+ import { LoopType } from "../engine/types";
13
+ const EDGE_WIDTH = [1.6, 2.5, 3.4];
14
+ /**
15
+ * Canvas label font. The desktop app renders its canvas text in Helvetica Neue
16
+ * (`ThemeData.fontFamily` in `app/lib/main.dart`) at weight 600. A
17
+ * `var(--font-interface)` inside a canvas `font` string is NOT resolved by the
18
+ * 2D context — the weight silently falls back to 400 and the family to the
19
+ * system UI font — so the family and weight are spelled out concretely here to
20
+ * match the app pixel-for-pixel. The monospace badge font mirrors Dart's
21
+ * generic `'monospace'`.
22
+ */
23
+ export const LABEL_FONT = '"Helvetica Neue", Helvetica, Arial, sans-serif';
24
+ const MONO_FONT = "monospace";
25
+ /** Matches the app's `letterSpacing: -0.1` on every canvas label. */
26
+ export const LABEL_TRACKING = "-0.1px";
27
+ /** Set canvas letter-spacing without depending on it being in the DOM lib. */
28
+ function setLetterSpacing(ctx, v) {
29
+ ctx.letterSpacing = v;
30
+ }
31
+ export function paint(ctx, scene, camera, theme, ui) {
32
+ ctx.setTransform(ui.dpr, 0, 0, ui.dpr, 0, 0);
33
+ ctx.fillStyle = theme.paper;
34
+ ctx.fillRect(0, 0, ui.cssWidth, ui.cssHeight);
35
+ drawGrid(ctx, camera, theme, ui);
36
+ ctx.translate(camera.tx, camera.ty);
37
+ ctx.scale(camera.scale, camera.scale);
38
+ const hl = ui.loopHighlight;
39
+ for (const g of scene.edges) {
40
+ drawEdge(ctx, g, theme, g.id === ui.selectedEdgeId, hl, ui.flowPhase);
41
+ }
42
+ for (const n of scene.nodes) {
43
+ const box = scene.boxes.get(n.id);
44
+ if (box) {
45
+ drawNode(ctx, box, n, theme, n.id === ui.selectedNodeId, ui.liveNodeIds.has(n.id), hl ? !hl.nodeIds.has(n.id) : false, n.id === ui.selectedNodeId ? ui.pulsePhase : 0);
46
+ }
47
+ }
48
+ for (const lp of scene.loops) {
49
+ const c = scene.badges.get(lp.key);
50
+ if (c) {
51
+ drawBadge(ctx, c, scene.labels.get(lp.key) ?? "", lp.type, theme, lp.key === ui.selectedLoopKey, loopClockwise(lp.nodeIds, scene.boxes));
52
+ }
53
+ }
54
+ if (ui.linkPreview) {
55
+ const box = scene.boxes.get(ui.linkPreview.from);
56
+ if (box)
57
+ drawLinkPreview(ctx, { x: box.cx, y: box.cy }, ui.linkPreview.to, theme);
58
+ }
59
+ }
60
+ function drawGrid(ctx, camera, theme, ui) {
61
+ if (camera.scale < 0.25)
62
+ return;
63
+ const step = 32;
64
+ const tl = camera.toWorld(0, 0);
65
+ const br = camera.toWorld(ui.cssWidth, ui.cssHeight);
66
+ const x0 = Math.floor(tl.x / step) * step;
67
+ const y0 = Math.floor(tl.y / step) * step;
68
+ ctx.fillStyle = withAlpha(theme.line, theme.dark ? 0.6 : 0.9);
69
+ for (let wx = x0; wx <= br.x; wx += step) {
70
+ for (let wy = y0; wy <= br.y; wy += step) {
71
+ const p = camera.toScreen(wx, wy);
72
+ ctx.fillRect(p.x, p.y, 1, 1);
73
+ }
74
+ }
75
+ }
76
+ /** Append a rounded-rect sub-path to the current path (no beginPath). */
77
+ function roundRectInto(ctx, x, y, w, h, r) {
78
+ const rr = Math.min(r, w / 2, h / 2);
79
+ ctx.moveTo(x + rr, y);
80
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
81
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
82
+ ctx.arcTo(x, y + h, x, y, rr);
83
+ ctx.arcTo(x, y, x + w, y, rr);
84
+ ctx.closePath();
85
+ }
86
+ function roundRect(ctx, x, y, w, h, r) {
87
+ ctx.beginPath();
88
+ roundRectInto(ctx, x, y, w, h, r);
89
+ }
90
+ function circle(ctx, c, r) {
91
+ ctx.beginPath();
92
+ ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
93
+ }
94
+ function drawNode(ctx, box, node, theme, selected, live, dim, pulse) {
95
+ const x = box.cx - box.w / 2;
96
+ const y = box.cy - box.h / 2;
97
+ const r = box.type === "stock" ? 5 : box.h / 2;
98
+ ctx.save();
99
+ if (dim)
100
+ ctx.globalAlpha = 0.18;
101
+ if (selected) {
102
+ // Soft teal shade radiating from the rim — the app's `Motion.selectPulse`:
103
+ // a blurred ring (inflated stadium minus the node body) that swells and
104
+ // fades, drawn instead of any hard outline. Ported from the Dart
105
+ // MaskFilter.blur shade, so under reduced motion (pulse=0) it rests as a
106
+ // quiet glow hugging the node.
107
+ const grow = 6 + 26 * pulse;
108
+ ctx.save();
109
+ ctx.filter = "blur(5px)";
110
+ ctx.beginPath();
111
+ roundRectInto(ctx, x - grow, y - grow, box.w + grow * 2, box.h + grow * 2, (box.h + grow * 2) / 2);
112
+ roundRectInto(ctx, x, y, box.w, box.h, r);
113
+ ctx.fillStyle = withAlpha(theme.teal, 0.26 * (1 - pulse));
114
+ ctx.fill("evenodd");
115
+ ctx.restore();
116
+ }
117
+ if (live) {
118
+ roundRect(ctx, x - 11, y - 11, box.w + 22, box.h + 22, 22);
119
+ ctx.fillStyle = withAlpha(theme.live, 0.12);
120
+ ctx.fill();
121
+ roundRect(ctx, x - 6, y - 6, box.w + 12, box.h + 12, 18);
122
+ ctx.lineWidth = 2;
123
+ ctx.strokeStyle = theme.live;
124
+ ctx.stroke();
125
+ }
126
+ if (selected) {
127
+ // Thin steady ring at the connect radius marks the drag-to-link boundary
128
+ // (a solid hairline in the app, not a dashed ring).
129
+ const gap = 20;
130
+ roundRect(ctx, x - gap, y - gap, box.w + gap * 2, box.h + gap * 2, (box.h + gap * 2) / 2);
131
+ ctx.lineWidth = 1;
132
+ ctx.strokeStyle = withAlpha(theme.teal, 0.55);
133
+ ctx.stroke();
134
+ }
135
+ // Optional group tint: soft fill + ink + border, dodging the reserved R/B/live
136
+ // hues. Selection / live / loop colors render around it and still win. Ported
137
+ // from the app's `_paintNode` (stock keeps its graphite border regardless).
138
+ const swatch = groupSwatch(node.group);
139
+ roundRect(ctx, x, y, box.w, box.h, r);
140
+ ctx.fillStyle = swatch ? swatchFill(swatch, theme.dark) : theme.surface;
141
+ ctx.fill();
142
+ ctx.lineWidth = box.type === "stock" ? 1.6 : 1.3;
143
+ ctx.strokeStyle =
144
+ swatch && box.type !== "stock"
145
+ ? swatchBorder(swatch, theme.dark)
146
+ : box.type === "stock"
147
+ ? theme.graphite
148
+ : theme.line2;
149
+ ctx.stroke();
150
+ if (box.type === "stock") {
151
+ ctx.strokeStyle = theme.graphite;
152
+ ctx.lineWidth = 2.4;
153
+ for (const bx of [x + 1, x + box.w - 1]) {
154
+ ctx.beginPath();
155
+ ctx.moveTo(bx, y + 6);
156
+ ctx.lineTo(bx, y + box.h - 6);
157
+ ctx.stroke();
158
+ }
159
+ }
160
+ else if (box.type === "flow") {
161
+ drawValve(ctx, x + 13, box.cy, theme);
162
+ }
163
+ ctx.fillStyle = swatch ? swatchInk(swatch, theme.dark) : theme.ink;
164
+ ctx.font = "600 13px " + LABEL_FONT;
165
+ setLetterSpacing(ctx, LABEL_TRACKING);
166
+ ctx.textAlign = "center";
167
+ ctx.textBaseline = "middle";
168
+ const labelX = box.cx + (box.type === "flow" ? 8 : 0);
169
+ const maxW = box.w - (box.type === "flow" ? 26 : 14);
170
+ ctx.fillText(fitText(ctx, node.label, maxW), labelX, box.cy);
171
+ // Top-LEFT corner badge when the node drills into a child model (`subsystem`
172
+ // link set). Empty string is falsy in JS, so this mirrors Dart's
173
+ // `subsystem != null && subsystem.isNotEmpty`.
174
+ if (node.subsystem)
175
+ drawSubsystemMark(ctx, x + 10, y + 10, theme);
176
+ ctx.restore();
177
+ }
178
+ /**
179
+ * Stacked-sheets "layers" glyph marking that a node has a connected subsystem,
180
+ * in subtle theme ink (`ink2`) so it reads as "open me" while staying distinct
181
+ * from selection teal and loop R/B hues. Mirrors `_paintSubsystemMark` (which
182
+ * paints Material `Icons.layers`) in `app/lib/painters/graph_painter.dart`;
183
+ * hand-drawn here because a canvas 2D context can't resolve the Material icon
184
+ * font. Centered on (cx, cy) within a ~16px box, matching the app's
185
+ * `rect.left + 10, rect.top + 10` placement.
186
+ */
187
+ function drawSubsystemMark(ctx, cx, cy, theme) {
188
+ ctx.strokeStyle = theme.ink2;
189
+ ctx.lineWidth = 1.4;
190
+ ctx.lineJoin = "round";
191
+ ctx.lineCap = "round";
192
+ // Front sheet: a rhombus filling the upper half of the icon box.
193
+ ctx.beginPath();
194
+ ctx.moveTo(cx, cy - 7);
195
+ ctx.lineTo(cx + 7, cy - 2.5);
196
+ ctx.lineTo(cx, cy + 2);
197
+ ctx.lineTo(cx - 7, cy - 2.5);
198
+ ctx.closePath();
199
+ ctx.stroke();
200
+ // Back sheet: a chevron peeking out beneath the front rhombus.
201
+ ctx.beginPath();
202
+ ctx.moveTo(cx - 7, cy + 0.5);
203
+ ctx.lineTo(cx, cy + 5);
204
+ ctx.lineTo(cx + 7, cy + 0.5);
205
+ ctx.stroke();
206
+ }
207
+ function drawValve(ctx, cx, cy, theme) {
208
+ // Stock-and-flow valve: two triangles meeting tip-to-tip (a bowtie ▷◁).
209
+ // Each sub-path must be closed so its outer base edge is drawn — otherwise
210
+ // the four spokes read as an ✕, not a valve (matches the Dart painter).
211
+ ctx.strokeStyle = theme.graphite;
212
+ ctx.lineWidth = 1.5;
213
+ ctx.lineJoin = "round";
214
+ ctx.beginPath();
215
+ ctx.moveTo(cx - 6, cy - 6);
216
+ ctx.lineTo(cx, cy);
217
+ ctx.lineTo(cx - 6, cy + 6);
218
+ ctx.closePath();
219
+ ctx.moveTo(cx + 6, cy - 6);
220
+ ctx.lineTo(cx, cy);
221
+ ctx.lineTo(cx + 6, cy + 6);
222
+ ctx.closePath();
223
+ ctx.stroke();
224
+ }
225
+ function drawEdge(ctx, g, theme, selected, hl, flowPhase) {
226
+ const inLoop = hl ? hl.edgeIds.has(g.id) : false;
227
+ const dim = hl ? !inLoop : false;
228
+ const loopColor = inLoop ? (hl?.type === LoopType.reinforcing ? theme.teal : theme.amber) : null;
229
+ ctx.save();
230
+ if (dim)
231
+ ctx.globalAlpha = 0.16;
232
+ const w = EDGE_WIDTH[Math.max(0, Math.min(2, g.link.weight ?? 0))];
233
+ ctx.lineCap = "round";
234
+ ctx.lineJoin = "round";
235
+ // Soft glow: a wide, faint band under a highlighted loop's edges (loop hue)
236
+ // or a selected edge (teal), so the active line pops off the canvas.
237
+ if (loopColor) {
238
+ tracePath(ctx, g.points);
239
+ ctx.lineWidth = 9;
240
+ ctx.strokeStyle = withAlpha(loopColor, 0.22);
241
+ ctx.stroke();
242
+ }
243
+ else if (selected) {
244
+ tracePath(ctx, g.points);
245
+ ctx.lineWidth = 7;
246
+ ctx.strokeStyle = withAlpha(theme.teal, 0.22);
247
+ ctx.stroke();
248
+ }
249
+ if (loopColor) {
250
+ // Highlighted loop edge: coloured marching ants ONLY (no continuous line
251
+ // under them), gaps left open over the glow — the app's `_drawMarching`.
252
+ tracePath(ctx, g.points);
253
+ ctx.lineWidth = 3.4;
254
+ ctx.strokeStyle = loopColor;
255
+ ctx.setLineDash([7, 6]);
256
+ ctx.lineDashOffset = -(flowPhase * 13);
257
+ ctx.stroke();
258
+ ctx.setLineDash([]);
259
+ ctx.lineDashOffset = 0;
260
+ }
261
+ else if (g.link.indirect) {
262
+ // Cosmetic dotted line (independent of polarity).
263
+ tracePath(ctx, g.points);
264
+ ctx.lineWidth = w;
265
+ ctx.strokeStyle = theme.graphite;
266
+ ctx.setLineDash([1, 5]);
267
+ ctx.stroke();
268
+ ctx.setLineDash([]);
269
+ }
270
+ else {
271
+ tracePath(ctx, g.points);
272
+ ctx.lineWidth = selected ? w + 0.8 : w;
273
+ ctx.strokeStyle = selected ? theme.teal : theme.graphite;
274
+ ctx.stroke();
275
+ }
276
+ const arrowColor = loopColor ?? theme.graphite;
277
+ drawArrow(ctx, g.arrowTip, g.arrowAngle, arrowColor);
278
+ if (g.link.delay)
279
+ drawDelay(ctx, g.delay, g.delayAngle, theme);
280
+ if (g.link.nonlinear)
281
+ drawNonlinear(ctx, g.nl, g.nlAngle, theme);
282
+ drawPolarityChip(ctx, g.mid, g.link.polarity, theme, selected);
283
+ ctx.restore();
284
+ }
285
+ function tracePath(ctx, pts) {
286
+ ctx.beginPath();
287
+ ctx.moveTo(pts[0].x, pts[0].y);
288
+ for (let i = 1; i < pts.length; i++)
289
+ ctx.lineTo(pts[i].x, pts[i].y);
290
+ }
291
+ function drawArrow(ctx, tip, angle, color) {
292
+ const size = 8;
293
+ const perp = size * 0.55;
294
+ const bx = tip.x - Math.cos(angle) * size;
295
+ const by = tip.y - Math.sin(angle) * size;
296
+ const nx = -Math.sin(angle);
297
+ const ny = Math.cos(angle);
298
+ ctx.beginPath();
299
+ ctx.moveTo(tip.x, tip.y);
300
+ ctx.lineTo(bx + nx * perp, by + ny * perp);
301
+ ctx.lineTo(bx - nx * perp, by - ny * perp);
302
+ ctx.closePath();
303
+ ctx.fillStyle = color;
304
+ ctx.fill();
305
+ }
306
+ function drawPolarityChip(ctx, c, polarity, theme, selected) {
307
+ circle(ctx, c, 9);
308
+ ctx.fillStyle = theme.surface;
309
+ ctx.fill();
310
+ ctx.lineWidth = 1.2;
311
+ ctx.strokeStyle = theme.line2;
312
+ ctx.stroke();
313
+ if (selected) {
314
+ circle(ctx, c, 13);
315
+ ctx.lineWidth = 2;
316
+ ctx.strokeStyle = theme.teal;
317
+ ctx.stroke();
318
+ }
319
+ ctx.fillStyle = theme.ink;
320
+ ctx.font = "700 15px " + LABEL_FONT;
321
+ setLetterSpacing(ctx, LABEL_TRACKING);
322
+ ctx.textAlign = "center";
323
+ ctx.textBaseline = "middle";
324
+ // En-dash (U+2013) for the negative glyph, matching the app.
325
+ ctx.fillText(polarity === "-" ? "–" : "+", c.x, c.y + 0.5);
326
+ }
327
+ function drawDelay(ctx, c, angle, theme) {
328
+ ctx.save();
329
+ ctx.translate(c.x, c.y);
330
+ // Delay ticks read as a "‖" CROSSING the edge: each tick perpendicular to the
331
+ // flow, the pair offset along it. Rotating by the tangent angle (not +90°)
332
+ // makes the two vertical bars stand across the line, not lie along it.
333
+ ctx.rotate(angle);
334
+ ctx.strokeStyle = theme.graphite;
335
+ ctx.lineWidth = 1.8;
336
+ ctx.lineCap = "round";
337
+ for (const dx of [-2.6, 2.6]) {
338
+ ctx.beginPath();
339
+ ctx.moveTo(dx, -6);
340
+ ctx.lineTo(dx, 6);
341
+ ctx.stroke();
342
+ }
343
+ ctx.restore();
344
+ }
345
+ function drawNonlinear(ctx, c, angle, theme) {
346
+ ctx.save();
347
+ ctx.translate(c.x, c.y);
348
+ ctx.rotate(angle + Math.PI / 2);
349
+ ctx.strokeStyle = theme.graphite;
350
+ ctx.lineWidth = 1.6;
351
+ ctx.lineCap = "round";
352
+ ctx.beginPath();
353
+ for (let x = -8; x <= 8; x += 1) {
354
+ const y = 3 * Math.sin((x / 6) * Math.PI * 2);
355
+ if (x === -8)
356
+ ctx.moveTo(x, y);
357
+ else
358
+ ctx.lineTo(x, y);
359
+ }
360
+ ctx.stroke();
361
+ ctx.restore();
362
+ }
363
+ /** True when the loop's member nodes wind clockwise on screen (y-down), so the
364
+ * badge's rotor matches the real flow direction. Mirrors `loopClockwise` in the
365
+ * app's CanvasController. */
366
+ function loopClockwise(nodeIds, boxes) {
367
+ const order = [];
368
+ for (const id of nodeIds) {
369
+ if (order.length && order[order.length - 1] === id)
370
+ continue;
371
+ order.push(id);
372
+ }
373
+ if (order.length > 1 && order[0] === order[order.length - 1])
374
+ order.pop();
375
+ const poly = [];
376
+ for (const id of order) {
377
+ const b = boxes.get(id);
378
+ if (b)
379
+ poly.push({ x: b.cx, y: b.cy });
380
+ }
381
+ if (poly.length < 3)
382
+ return true;
383
+ let a = 0;
384
+ for (let i = 0; i < poly.length; i++) {
385
+ const p0 = poly[i];
386
+ const p1 = poly[(i + 1) % poly.length];
387
+ a += p0.x * p1.y - p1.x * p0.y;
388
+ }
389
+ return a > 0;
390
+ }
391
+ function drawBadge(ctx, c, label, type, theme, selected, clockwise) {
392
+ const r = selected ? 19 : 14;
393
+ const reinforcing = type === LoopType.reinforcing;
394
+ const color = reinforcing ? theme.tealInk : theme.amberInk;
395
+ const soft = reinforcing ? theme.tealSoft : theme.amberSoft;
396
+ if (selected) {
397
+ circle(ctx, c, r + 5);
398
+ ctx.lineWidth = 2;
399
+ ctx.strokeStyle = color;
400
+ ctx.stroke();
401
+ }
402
+ circle(ctx, c, r);
403
+ ctx.fillStyle = theme.surface;
404
+ ctx.fill();
405
+ circle(ctx, c, r);
406
+ ctx.fillStyle = withAlpha(soft, 0.6);
407
+ ctx.fill();
408
+ circle(ctx, c, r);
409
+ ctx.lineWidth = selected ? 1.8 : 1.4;
410
+ ctx.strokeStyle = color;
411
+ ctx.stroke();
412
+ // Circular loop arrow on the emphasised (selected) badge, sweeping in the
413
+ // loop's real flow direction — the app's `_loopBadge` rotor.
414
+ if (selected) {
415
+ const ar = 24;
416
+ const start = clockwise ? -Math.PI * 0.55 : Math.PI * 0.55;
417
+ const sweep = (clockwise ? 1 : -1) * Math.PI * 1.5;
418
+ const end = start + sweep;
419
+ ctx.beginPath();
420
+ ctx.arc(c.x, c.y, ar, start, end, sweep < 0);
421
+ ctx.lineWidth = 1.6;
422
+ ctx.lineCap = "round";
423
+ ctx.strokeStyle = color;
424
+ ctx.stroke();
425
+ const dir = clockwise ? 1 : -1;
426
+ const tan = { x: -Math.sin(end) * dir, y: Math.cos(end) * dir };
427
+ const perp = { x: -tan.y, y: tan.x };
428
+ const tip = { x: c.x + Math.cos(end) * ar, y: c.y + Math.sin(end) * ar };
429
+ const s = 5;
430
+ ctx.beginPath();
431
+ ctx.moveTo(tip.x, tip.y);
432
+ ctx.lineTo(tip.x - tan.x * s + perp.x * (s * 0.5), tip.y - tan.y * s + perp.y * (s * 0.5));
433
+ ctx.lineTo(tip.x - tan.x * s - perp.x * (s * 0.5), tip.y - tan.y * s - perp.y * (s * 0.5));
434
+ ctx.closePath();
435
+ ctx.fillStyle = color;
436
+ ctx.fill();
437
+ }
438
+ let size = selected ? 17 : 13;
439
+ if (label.length >= 3)
440
+ size -= 4;
441
+ else if (label.length === 2)
442
+ size -= 2;
443
+ ctx.fillStyle = color;
444
+ ctx.font = `800 ${size}px ${MONO_FONT}`;
445
+ setLetterSpacing(ctx, "0px");
446
+ ctx.textAlign = "center";
447
+ ctx.textBaseline = "middle";
448
+ ctx.fillText(label, c.x, c.y + 0.5);
449
+ }
450
+ function drawLinkPreview(ctx, from, to, theme) {
451
+ ctx.setLineDash([5, 4]);
452
+ ctx.lineWidth = 1.6;
453
+ ctx.strokeStyle = theme.teal;
454
+ ctx.beginPath();
455
+ ctx.moveTo(from.x, from.y);
456
+ ctx.lineTo(to.x, to.y);
457
+ ctx.stroke();
458
+ ctx.setLineDash([]);
459
+ circle(ctx, to, 4);
460
+ ctx.fillStyle = theme.teal;
461
+ ctx.fill();
462
+ }
463
+ function fitText(ctx, text, maxW) {
464
+ if (ctx.measureText(text).width <= maxW)
465
+ return text;
466
+ let s = text;
467
+ while (s.length > 1 && ctx.measureText(s + "…").width > maxW) {
468
+ s = s.slice(0, -1);
469
+ }
470
+ return s + "…";
471
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * SceneCache — builds the renderable `Scene` (node boxes, curved edges, loop
3
+ * badges) from a `GraphView`, and caches the heavy parts:
4
+ *
5
+ * - **Label-width memo:** each distinct label is measured once (via the canvas
6
+ * `measureText`), not per node per rebuild.
7
+ * - **Dirty-tracking:** a rebuild is skipped — the previous `Scene` object is
8
+ * returned by reference — when no geometric input changed (graph identity,
9
+ * node positions/labels/types, link curvature, bow signs, badge overrides).
10
+ * Idle renders and live-edit reloads stop rebuilding geometry needlessly; a
11
+ * drag still rebuilds because the moved node changes the signature.
12
+ *
13
+ * Pure of canvas-view state: it owns only its measure context and memo, and is
14
+ * handed the graph + interaction caches to build from.
15
+ */
16
+ import { Camera, Point } from "./camera";
17
+ import { GraphView } from "../engine/engine";
18
+ import { Scene } from "./painter";
19
+ /** Measure a label's pixel width at the canvas label font. */
20
+ export type LabelMeasurer = (label: string) => number;
21
+ /**
22
+ * A canvas-backed label measurer matching the painter font (and the app's
23
+ * `NodeBox.sizeFor`). Falls back to the character-count estimate that
24
+ * `buildNodeBoxes` uses when there's no canvas (tests/headless), so box sizing
25
+ * stays deterministic either way.
26
+ */
27
+ export declare function canvasLabelMeasurer(): LabelMeasurer;
28
+ export declare class SceneCache {
29
+ private readonly rawMeasure;
30
+ private readonly widthMemo;
31
+ private scene;
32
+ private lastGraph;
33
+ private lastSig;
34
+ constructor(rawMeasure?: LabelMeasurer);
35
+ /** Measure a label width, memoized so each distinct label costs one measure. */
36
+ private measure;
37
+ /** Drop the label-width memo — call if the measure font/context ever changes. */
38
+ invalidateMeasurements(): void;
39
+ /** Force the next `build` to recompute even if the signature is unchanged. */
40
+ invalidate(): void;
41
+ /**
42
+ * Build (or reuse) the `Scene` for `graph`. Returns the previous `Scene` by
43
+ * reference when no geometric input moved; otherwise recomputes boxes, edges,
44
+ * and badges. Returns `null` for a null graph.
45
+ */
46
+ build(graph: GraphView | null, bowSigns: Map<string, number>, badgeOverrides: Map<string, Point>): Scene | null;
47
+ /**
48
+ * Fit `camera` to the current scene's node bounds. No-op (returns `false`)
49
+ * when there's no scene, no nodes, or a zero-sized viewport — the caller keeps
50
+ * the "fit only once" policy via its own camera-memory flag.
51
+ */
52
+ fit(camera: Camera, width: number, height: number): boolean;
53
+ /**
54
+ * A cheap signature of every input that changes box/edge/badge geometry:
55
+ * node id·position·type·label and each outgoing link's target·curvature (a bow
56
+ * drag mutates curvature in place), plus the frozen bow signs and the
57
+ * session-only badge overrides. Link polarity/flags don't affect geometry and
58
+ * arrive via a fresh graph (caught by the identity check), so they're omitted.
59
+ */
60
+ private signature;
61
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * SceneCache — builds the renderable `Scene` (node boxes, curved edges, loop
3
+ * badges) from a `GraphView`, and caches the heavy parts:
4
+ *
5
+ * - **Label-width memo:** each distinct label is measured once (via the canvas
6
+ * `measureText`), not per node per rebuild.
7
+ * - **Dirty-tracking:** a rebuild is skipped — the previous `Scene` object is
8
+ * returned by reference — when no geometric input changed (graph identity,
9
+ * node positions/labels/types, link curvature, bow signs, badge overrides).
10
+ * Idle renders and live-edit reloads stop rebuilding geometry needlessly; a
11
+ * drag still rebuilds because the moved node changes the signature.
12
+ *
13
+ * Pure of canvas-view state: it owns only its measure context and memo, and is
14
+ * handed the graph + interaction caches to build from.
15
+ */
16
+ import { buildEdgeGeoms, buildNodeBoxes, collectEdges, computeBadges, nodeBounds, } from "./geometry";
17
+ import { LABEL_FONT, LABEL_TRACKING } from "./painter";
18
+ /**
19
+ * A canvas-backed label measurer matching the painter font (and the app's
20
+ * `NodeBox.sizeFor`). Falls back to the character-count estimate that
21
+ * `buildNodeBoxes` uses when there's no canvas (tests/headless), so box sizing
22
+ * stays deterministic either way.
23
+ */
24
+ export function canvasLabelMeasurer() {
25
+ const ctx = typeof activeDocument !== "undefined"
26
+ ? activeDocument.createElement("canvas").getContext("2d")
27
+ : null;
28
+ if (!ctx)
29
+ return (label) => label.length * 7.2;
30
+ return (label) => {
31
+ ctx.font = "600 13px " + LABEL_FONT;
32
+ ctx.letterSpacing = LABEL_TRACKING;
33
+ return ctx.measureText(label).width;
34
+ };
35
+ }
36
+ export class SceneCache {
37
+ constructor(rawMeasure = canvasLabelMeasurer()) {
38
+ this.widthMemo = new Map();
39
+ this.scene = null;
40
+ this.lastGraph = null;
41
+ this.lastSig = null;
42
+ this.rawMeasure = rawMeasure;
43
+ }
44
+ /** Measure a label width, memoized so each distinct label costs one measure. */
45
+ measure(label) {
46
+ let w = this.widthMemo.get(label);
47
+ if (w === undefined) {
48
+ w = this.rawMeasure(label);
49
+ this.widthMemo.set(label, w);
50
+ }
51
+ return w;
52
+ }
53
+ /** Drop the label-width memo — call if the measure font/context ever changes. */
54
+ invalidateMeasurements() {
55
+ this.widthMemo.clear();
56
+ }
57
+ /** Force the next `build` to recompute even if the signature is unchanged. */
58
+ invalidate() {
59
+ this.lastGraph = null;
60
+ this.lastSig = null;
61
+ }
62
+ /**
63
+ * Build (or reuse) the `Scene` for `graph`. Returns the previous `Scene` by
64
+ * reference when no geometric input moved; otherwise recomputes boxes, edges,
65
+ * and badges. Returns `null` for a null graph.
66
+ */
67
+ build(graph, bowSigns, badgeOverrides) {
68
+ if (!graph) {
69
+ this.scene = null;
70
+ this.lastGraph = null;
71
+ this.lastSig = null;
72
+ return null;
73
+ }
74
+ const sig = this.signature(graph, bowSigns, badgeOverrides);
75
+ if (this.scene && graph === this.lastGraph && sig === this.lastSig)
76
+ return this.scene;
77
+ const boxes = buildNodeBoxes(graph.nodes, (s) => this.measure(s));
78
+ const edges = buildEdgeGeoms(collectEdges(graph.nodes), boxes, bowSigns);
79
+ const badges = computeBadges(graph.loops, boxes, badgeOverrides);
80
+ this.scene = {
81
+ nodes: graph.nodes,
82
+ boxes,
83
+ edges,
84
+ loops: graph.loops,
85
+ labels: graph.labels,
86
+ badges,
87
+ };
88
+ this.lastGraph = graph;
89
+ // `buildEdgeGeoms` freezes a bow sign for each previously-unseen edge *into*
90
+ // `bowSigns`, which feeds the signature — so the pre-build `sig` is already
91
+ // stale. Record the post-build signature instead, or the next unchanged call
92
+ // would needlessly rebuild once before the cache settles.
93
+ this.lastSig = this.signature(graph, bowSigns, badgeOverrides);
94
+ return this.scene;
95
+ }
96
+ /**
97
+ * Fit `camera` to the current scene's node bounds. No-op (returns `false`)
98
+ * when there's no scene, no nodes, or a zero-sized viewport — the caller keeps
99
+ * the "fit only once" policy via its own camera-memory flag.
100
+ */
101
+ fit(camera, width, height) {
102
+ if (!this.scene || this.scene.boxes.size === 0)
103
+ return false;
104
+ if (width === 0 || height === 0)
105
+ return false;
106
+ camera.fit(nodeBounds(this.scene.boxes), width, height);
107
+ return true;
108
+ }
109
+ /**
110
+ * A cheap signature of every input that changes box/edge/badge geometry:
111
+ * node id·position·type·label and each outgoing link's target·curvature (a bow
112
+ * drag mutates curvature in place), plus the frozen bow signs and the
113
+ * session-only badge overrides. Link polarity/flags don't affect geometry and
114
+ * arrive via a fresh graph (caught by the identity check), so they're omitted.
115
+ */
116
+ signature(graph, bowSigns, badgeOverrides) {
117
+ const parts = [];
118
+ for (const n of graph.nodes) {
119
+ parts.push(`${n.id}:${n.x}:${n.y}:${n.type}:${n.label}`);
120
+ for (const l of n.links)
121
+ parts.push(`>${l.to}:${l.curvature ?? ""}`);
122
+ }
123
+ parts.push("|");
124
+ for (const [k, v] of bowSigns)
125
+ parts.push(`${k}=${v}`);
126
+ parts.push("|");
127
+ for (const [k, p] of badgeOverrides)
128
+ parts.push(`${k}=${p.x},${p.y}`);
129
+ parts.push("|");
130
+ for (const l of graph.loops)
131
+ parts.push(l.key);
132
+ return parts.join(";");
133
+ }
134
+ }