trace-viz 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -0
  4. package/dist/adapters/index.d.ts +32 -0
  5. package/dist/adapters/index.d.ts.map +1 -0
  6. package/dist/adapters/index.js +83 -0
  7. package/dist/adapters/index.js.map +1 -0
  8. package/dist/agent.d.ts +66 -0
  9. package/dist/agent.d.ts.map +1 -0
  10. package/dist/agent.js +329 -0
  11. package/dist/agent.js.map +1 -0
  12. package/dist/atlas/atlas.d.ts +203 -0
  13. package/dist/atlas/atlas.d.ts.map +1 -0
  14. package/dist/atlas/atlas.js +1466 -0
  15. package/dist/atlas/atlas.js.map +1 -0
  16. package/dist/atlas/index.d.ts +7 -0
  17. package/dist/atlas/index.d.ts.map +1 -0
  18. package/dist/atlas/index.js +4 -0
  19. package/dist/atlas/index.js.map +1 -0
  20. package/dist/atlas/layout.d.ts +44 -0
  21. package/dist/atlas/layout.d.ts.map +1 -0
  22. package/dist/atlas/layout.js +103 -0
  23. package/dist/atlas/layout.js.map +1 -0
  24. package/dist/atlas/model.d.ts +164 -0
  25. package/dist/atlas/model.d.ts.map +1 -0
  26. package/dist/atlas/model.js +489 -0
  27. package/dist/atlas/model.js.map +1 -0
  28. package/dist/channels.d.ts +105 -0
  29. package/dist/channels.d.ts.map +1 -0
  30. package/dist/channels.js +411 -0
  31. package/dist/channels.js.map +1 -0
  32. package/dist/components.d.ts +76 -0
  33. package/dist/components.d.ts.map +1 -0
  34. package/dist/components.js +432 -0
  35. package/dist/components.js.map +1 -0
  36. package/dist/estate.d.ts +25 -0
  37. package/dist/estate.d.ts.map +1 -0
  38. package/dist/estate.js +285 -0
  39. package/dist/estate.js.map +1 -0
  40. package/dist/failure.d.ts +36 -0
  41. package/dist/failure.d.ts.map +1 -0
  42. package/dist/failure.js +82 -0
  43. package/dist/failure.js.map +1 -0
  44. package/dist/geometry.d.ts +24 -0
  45. package/dist/geometry.d.ts.map +1 -0
  46. package/dist/geometry.js +92 -0
  47. package/dist/geometry.js.map +1 -0
  48. package/dist/index.d.ts +51 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +34 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/layout.d.ts +66 -0
  53. package/dist/layout.d.ts.map +1 -0
  54. package/dist/layout.js +152 -0
  55. package/dist/layout.js.map +1 -0
  56. package/dist/layout3d.d.ts +98 -0
  57. package/dist/layout3d.d.ts.map +1 -0
  58. package/dist/layout3d.js +350 -0
  59. package/dist/layout3d.js.map +1 -0
  60. package/dist/mesh.d.ts +23 -0
  61. package/dist/mesh.d.ts.map +1 -0
  62. package/dist/mesh.js +263 -0
  63. package/dist/mesh.js.map +1 -0
  64. package/dist/model.d.ts +94 -0
  65. package/dist/model.d.ts.map +1 -0
  66. package/dist/model.js +84 -0
  67. package/dist/model.js.map +1 -0
  68. package/dist/particles.d.ts +80 -0
  69. package/dist/particles.d.ts.map +1 -0
  70. package/dist/particles.js +118 -0
  71. package/dist/particles.js.map +1 -0
  72. package/dist/project.d.ts +113 -0
  73. package/dist/project.d.ts.map +1 -0
  74. package/dist/project.js +288 -0
  75. package/dist/project.js.map +1 -0
  76. package/dist/react/index.d.ts +22 -0
  77. package/dist/react/index.d.ts.map +1 -0
  78. package/dist/react/index.js +69 -0
  79. package/dist/react/index.js.map +1 -0
  80. package/dist/renderer.d.ts +252 -0
  81. package/dist/renderer.d.ts.map +1 -0
  82. package/dist/renderer.js +1710 -0
  83. package/dist/renderer.js.map +1 -0
  84. package/dist/scales.d.ts +99 -0
  85. package/dist/scales.d.ts.map +1 -0
  86. package/dist/scales.js +126 -0
  87. package/dist/scales.js.map +1 -0
  88. package/dist/scene.d.ts +53 -0
  89. package/dist/scene.d.ts.map +1 -0
  90. package/dist/scene.js +44 -0
  91. package/dist/scene.js.map +1 -0
  92. package/dist/schema.d.ts +24 -0
  93. package/dist/schema.d.ts.map +1 -0
  94. package/dist/schema.js +131 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/seed.d.ts +33 -0
  97. package/dist/seed.d.ts.map +1 -0
  98. package/dist/seed.js +173 -0
  99. package/dist/seed.js.map +1 -0
  100. package/dist/theme.d.ts +35 -0
  101. package/dist/theme.d.ts.map +1 -0
  102. package/dist/theme.js +83 -0
  103. package/dist/theme.js.map +1 -0
  104. package/dist/trace-viz.esm.js +15 -0
  105. package/dist/trace-viz.min.js +15 -0
  106. package/dist/types.d.ts +92 -0
  107. package/dist/types.d.ts.map +1 -0
  108. package/dist/types.js +9 -0
  109. package/dist/types.js.map +1 -0
  110. package/package.json +105 -0
@@ -0,0 +1,1710 @@
1
+ import { couplingFor, styleScene, } from './channels.js';
2
+ import { emptyFailure, propagateFailure } from './failure.js';
3
+ import { COMPONENTS, componentProps, defaultTier, gaugeSpace, pickComponent, TIER_LABELS, } from './components.js';
4
+ import { distanceToPath, edgePath } from './geometry.js';
5
+ import { defaultWorldLayout, fitCamera, layoutWorld, project3, worldExtent, } from './layout3d.js';
6
+ import { defaultLayout, layoutGraph } from './layout.js';
7
+ import { describeTable } from './model.js';
8
+ import { EdgeStream, mulberry32 } from './particles.js';
9
+ import { project } from './project.js';
10
+ import { sceneFromGraph } from './scene.js';
11
+ import { defaultEncoding, edgeWidth, formatBytes, formatLatency, formatRps, rpsDomain, } from './scales.js';
12
+ import { resolveTheme } from './theme.js';
13
+ const WASTE_KINDS = new Set(['retry', 'challenge']);
14
+ const LEGEND_STRIP = 34;
15
+ /** Seconds the failure front takes to cross one hop. Slow enough to watch it arrive. */
16
+ const FRONT_HOP_SECONDS = 0.95;
17
+ export class TraceLight {
18
+ constructor(container, options = {}) {
19
+ this.insets = { top: 0, right: 0, bottom: 0, left: 0 };
20
+ this.table = null;
21
+ this.spec = { nodeKey: [] };
22
+ this.scene = { nodes: [], edges: [], meta: {}, nodeKey: [] };
23
+ this.styles = { edges: new Map(), nodes: new Map(), clusterBy: null, legend: [], issues: [] };
24
+ this.failure = emptyFailure({ nodes: [], edges: [], meta: {}, nodeKey: [] });
25
+ this.splitBy = [];
26
+ this.matched = 0;
27
+ this.filtered = 0;
28
+ this.targets = new Map();
29
+ this.origins = new Map();
30
+ this.positions = new Map();
31
+ this.morphFrom = 0;
32
+ this.morphing = false;
33
+ this.views = [];
34
+ this.killedAt = 0;
35
+ this.world = new Map();
36
+ this.camera = null;
37
+ this.yaw = 0.62;
38
+ this.yawVelocity = 0;
39
+ this.dragging = null;
40
+ /** A persistent selection, distinct from the transient hover. */
41
+ this.selected = null;
42
+ /** Isolate one value of one dimension: everything else dims. */
43
+ this.highlight = null;
44
+ /** Shockwaves from recent kills, drawn as expanding rings. */
45
+ this.shocks = [];
46
+ this.sprites = new Map();
47
+ this.width = 0;
48
+ this.height = 0;
49
+ this.dpr = 1;
50
+ this.raf = 0;
51
+ this.lastFrame = 0;
52
+ this.running = false;
53
+ this.hovered = null;
54
+ this.pointer = null;
55
+ // --- lifecycle -----------------------------------------------------------
56
+ this.onMotionChange = () => {
57
+ this.opts.animate = !prefersReducedMotion();
58
+ this.opts.morphMs = prefersReducedMotion() ? 0 : this.opts.morphMs;
59
+ this.draw();
60
+ };
61
+ this.onThemeChange = () => {
62
+ if (this.themeSetting === 'auto') {
63
+ this.theme = resolveTheme('auto');
64
+ this.applyScene(this.scene, this.spec, this.matched, this.filtered);
65
+ }
66
+ };
67
+ this.frame = () => {
68
+ if (!this.running)
69
+ return;
70
+ const t = now();
71
+ const dt = Math.min((t - this.lastFrame) / 1000, 0.1);
72
+ this.lastFrame = t;
73
+ if (this.morphing) {
74
+ const p = Math.min(1, (t - this.morphFrom) / this.opts.morphMs);
75
+ this.positions = interpolate(this.origins, this.targets, easeOutCubic(p));
76
+ for (const v of this.views)
77
+ v.path = this.pathFor(v.edge, v.offset);
78
+ if (p >= 1) {
79
+ this.morphing = false;
80
+ this.positions = new Map(this.targets);
81
+ for (const v of this.views)
82
+ v.path = this.pathFor(v.edge, v.offset);
83
+ this.placeLabels();
84
+ }
85
+ }
86
+ // Let a released drag coast to a stop. A hard stop reads as a glitch; a
87
+ // fast spin makes the mesh unreadable. Heavy damping gives a short glide.
88
+ if (!this.dragging && Math.abs(this.yawVelocity) > 0.00015 && this.isWorld()) {
89
+ this.yaw += this.yawVelocity * dt * 60;
90
+ this.yawVelocity *= Math.pow(0.86, dt * 60);
91
+ this.recamera();
92
+ }
93
+ else if (this.isWorld() &&
94
+ this.opts.orbit &&
95
+ this.opts.animate &&
96
+ !this.dragging &&
97
+ !this.hovered &&
98
+ !this.morphing) {
99
+ // A slow orbit - a full turn takes about two minutes - so the depth of
100
+ // the mesh keeps revealing itself without anyone having to drag. It
101
+ // pauses under the pointer, because a moving target cannot be read.
102
+ this.yaw += 0.05 * dt * this.opts.speed;
103
+ this.recamera();
104
+ }
105
+ this.shocks = this.shocks.filter((sh) => t - sh.at < 1600);
106
+ if (this.opts.animate && this.opts.speed > 0) {
107
+ const frontAge = (t - this.killedAt) / 1000;
108
+ for (const v of this.views) {
109
+ // A severed edge stops emitting once the front has passed through it.
110
+ const severed = this.failure.edgeHealth.get(v.edge.id) === 'severed';
111
+ const cut = severed && frontAge >= this.frontArrival(v.edge);
112
+ if (cut)
113
+ v.stream.stopEmitting();
114
+ v.stream.step(dt, v.path, this.encoding, this.opts.speed);
115
+ }
116
+ }
117
+ this.draw();
118
+ this.raf = requestAnimationFrame(this.frame);
119
+ };
120
+ this.onPointerDown = (ev) => {
121
+ if (!this.isWorld())
122
+ return;
123
+ this.dragging = { x: ev.clientX, yaw: this.yaw, lastX: ev.clientX, lastT: now() };
124
+ this.yawVelocity = 0;
125
+ this.canvas.setPointerCapture?.(ev.pointerId);
126
+ };
127
+ this.onPointerUp = (ev) => {
128
+ this.dragging = null;
129
+ this.canvas.releasePointerCapture?.(ev.pointerId);
130
+ };
131
+ this.onPointerMove = (ev) => {
132
+ const rect = this.canvas.getBoundingClientRect();
133
+ const x = ev.clientX - rect.left;
134
+ const y = ev.clientY - rect.top;
135
+ this.pointer = { x, y };
136
+ if (this.dragging) {
137
+ // Slow on purpose: a fast spin makes the mesh unreadable, and the point
138
+ // of turning it is to see round a cluster, not to whirl it.
139
+ this.yaw = this.dragging.yaw + (ev.clientX - this.dragging.x) * 0.005;
140
+ const t = now();
141
+ const dtMs = Math.max(t - this.dragging.lastT, 1);
142
+ this.yawVelocity = ((ev.clientX - this.dragging.lastX) * 0.005) / (dtMs / 16.7);
143
+ this.dragging.lastX = ev.clientX;
144
+ this.dragging.lastT = t;
145
+ this.recamera();
146
+ this.canvas.style.cursor = 'grabbing';
147
+ return;
148
+ }
149
+ const hit = this.hitTest(x, y);
150
+ const changed = targetId(hit) !== targetId(this.hovered);
151
+ this.hovered = hit;
152
+ this.canvas.style.cursor = hit ? 'pointer' : this.isWorld() ? 'grab' : 'default';
153
+ this.updateTooltip();
154
+ if (changed)
155
+ this.opts.onHover?.(hit);
156
+ };
157
+ this.onPointerLeave = () => {
158
+ this.pointer = null;
159
+ if (this.hovered)
160
+ this.opts.onHover?.(null);
161
+ this.hovered = null;
162
+ this.canvas.style.cursor = 'default';
163
+ this.updateTooltip();
164
+ };
165
+ this.onClick = () => {
166
+ this.opts.onSelect?.(this.hovered);
167
+ };
168
+ this.container = container;
169
+ this.themeSetting = options.theme ?? 'auto';
170
+ this.theme = resolveTheme(this.themeSetting);
171
+ this.encoding = { ...defaultEncoding, ...options.encoding };
172
+ this.layoutOpts = options.layout ?? {};
173
+ this.rand = mulberry32(options.seed ?? 1);
174
+ this.insets = { top: 0, right: 0, bottom: 0, left: 0, ...options.insets };
175
+ this.opts = {
176
+ showEdgeLabels: options.showEdgeLabels ?? false,
177
+ showLegend: options.showLegend ?? true,
178
+ speed: options.speed ?? 1,
179
+ animate: options.animate ?? !prefersReducedMotion(),
180
+ seed: options.seed ?? 1,
181
+ morphMs: options.morphMs ?? (prefersReducedMotion() ? 0 : 900),
182
+ orbit: options.orbit ?? !prefersReducedMotion(),
183
+ onHover: options.onHover,
184
+ onSelect: options.onSelect,
185
+ onView: options.onView,
186
+ };
187
+ if (getComputedStyle(container).position === 'static')
188
+ container.style.position = 'relative';
189
+ this.canvas = document.createElement('canvas');
190
+ this.canvas.style.display = 'block';
191
+ this.canvas.style.width = '100%';
192
+ this.canvas.style.height = '100%';
193
+ this.canvas.setAttribute('role', 'img');
194
+ container.appendChild(this.canvas);
195
+ const ctx = this.canvas.getContext('2d');
196
+ if (!ctx)
197
+ throw new Error('tracelight: 2d canvas context unavailable');
198
+ this.ctx = ctx;
199
+ this.tooltip = document.createElement('div');
200
+ Object.assign(this.tooltip.style, {
201
+ position: 'absolute',
202
+ pointerEvents: 'none',
203
+ opacity: '0',
204
+ transition: 'opacity 90ms ease',
205
+ font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace',
206
+ padding: '8px 10px',
207
+ borderRadius: '7px',
208
+ whiteSpace: 'pre',
209
+ zIndex: '2',
210
+ maxWidth: '320px',
211
+ boxShadow: '0 6px 20px rgba(0,0,0,.28)',
212
+ });
213
+ container.appendChild(this.tooltip);
214
+ this.canvas.addEventListener('pointerdown', this.onPointerDown);
215
+ this.canvas.addEventListener('pointerup', this.onPointerUp);
216
+ this.canvas.addEventListener('pointermove', this.onPointerMove);
217
+ this.canvas.addEventListener('pointerleave', this.onPointerLeave);
218
+ this.canvas.addEventListener('click', this.onClick);
219
+ if (typeof ResizeObserver !== 'undefined') {
220
+ this.resizeObserver = new ResizeObserver(() => this.resize());
221
+ this.resizeObserver.observe(container);
222
+ }
223
+ if (typeof matchMedia === 'function') {
224
+ this.motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
225
+ this.motionQuery.addEventListener?.('change', this.onMotionChange);
226
+ this.themeQuery = matchMedia('(prefers-color-scheme: dark)');
227
+ this.themeQuery.addEventListener?.('change', this.onThemeChange);
228
+ }
229
+ this.resize();
230
+ }
231
+ // --- public API ----------------------------------------------------------
232
+ /** Draw a hand-written flat graph. The v0.1 path; still supported unchanged. */
233
+ setGraph(graph) {
234
+ this.table = null;
235
+ this.applyScene(sceneFromGraph(graph), this.spec, graph.edges.length, 0);
236
+ }
237
+ /** Draw a dimensional flow table under a view spec. */
238
+ setTable(table, spec) {
239
+ this.table = table;
240
+ this.spec = spec ?? this.spec;
241
+ this.reproject();
242
+ }
243
+ /**
244
+ * Change the knobs. With a table loaded this re-projects (and morphs); with a
245
+ * flat graph it just restyles. Safe to call every frame.
246
+ */
247
+ setSpec(spec) {
248
+ const killedChanged = JSON.stringify(spec.killed ?? []) !== JSON.stringify(this.spec.killed ?? []);
249
+ this.spec = spec;
250
+ if (killedChanged)
251
+ this.killedAt = now();
252
+ if (this.table)
253
+ this.reproject();
254
+ else
255
+ this.applyScene(this.scene, spec, this.matched, this.filtered);
256
+ }
257
+ getSpec() {
258
+ return this.spec;
259
+ }
260
+ getScene() {
261
+ return this.scene;
262
+ }
263
+ getLegend() {
264
+ return this.styles.legend;
265
+ }
266
+ /** What dimensions and measures the loaded table offers. */
267
+ schema() {
268
+ return this.table ? describeTable(this.table) : null;
269
+ }
270
+ /** Kill nodes and watch the front travel. Pass [] to bring everything back. */
271
+ kill(ids) {
272
+ const before = new Set(this.spec.killed ?? []);
273
+ for (const id of ids)
274
+ if (!before.has(id))
275
+ this.shocks.push({ id, at: now() });
276
+ this.setSpec({ ...this.spec, killed: ids });
277
+ }
278
+ /** Current health of a node under the active kills: ok, degraded or dead. */
279
+ health(id) {
280
+ return this.failure.health.get(id) ?? 'ok';
281
+ }
282
+ /** Mark one node as selected. It keeps its ring until you pass null. */
283
+ select(id) {
284
+ this.selected = id;
285
+ this.draw();
286
+ }
287
+ getSelected() {
288
+ return this.selected;
289
+ }
290
+ /**
291
+ * Isolate one value of a dimension - "show me only eu-west" - by dimming
292
+ * every edge that does not carry it. Pass null to clear. Unlike a filter,
293
+ * nothing is re-projected: the rest of the picture stays where it is, faded,
294
+ * so you can see the isolated traffic *in context*.
295
+ */
296
+ setHighlight(highlight) {
297
+ this.highlight = highlight;
298
+ this.draw();
299
+ }
300
+ /** Nudge the world camera. Yaw is in radians; the renderer eases toward it. */
301
+ setYaw(yaw) {
302
+ this.yaw = yaw;
303
+ if (this.isWorld())
304
+ this.recamera();
305
+ }
306
+ getYaw() {
307
+ return this.yaw;
308
+ }
309
+ setOptions(options) {
310
+ if (options.theme !== undefined) {
311
+ this.themeSetting = options.theme;
312
+ this.theme = resolveTheme(options.theme);
313
+ }
314
+ if (options.encoding)
315
+ this.encoding = { ...this.encoding, ...options.encoding };
316
+ if (options.layout)
317
+ this.layoutOpts = { ...this.layoutOpts, ...options.layout };
318
+ if (options.showEdgeLabels !== undefined)
319
+ this.opts.showEdgeLabels = options.showEdgeLabels;
320
+ if (options.showLegend !== undefined)
321
+ this.opts.showLegend = options.showLegend;
322
+ if (options.speed !== undefined)
323
+ this.opts.speed = options.speed;
324
+ if (options.animate !== undefined)
325
+ this.opts.animate = options.animate;
326
+ if (options.morphMs !== undefined)
327
+ this.opts.morphMs = options.morphMs;
328
+ if (options.insets !== undefined)
329
+ this.insets = { top: 0, right: 0, bottom: 0, left: 0, ...options.insets };
330
+ if (options.orbit !== undefined)
331
+ this.opts.orbit = options.orbit;
332
+ if (options.onHover !== undefined)
333
+ this.opts.onHover = options.onHover;
334
+ if (options.onSelect !== undefined)
335
+ this.opts.onSelect = options.onSelect;
336
+ if (options.onView !== undefined)
337
+ this.opts.onView = options.onView;
338
+ this.applyScene(this.scene, this.spec, this.matched, this.filtered);
339
+ }
340
+ stats() {
341
+ let total = 0;
342
+ let wasted = 0;
343
+ for (const e of this.scene.edges) {
344
+ total += e.metrics.rps;
345
+ if (WASTE_KINDS.has(e.kind))
346
+ wasted += e.metrics.rps;
347
+ }
348
+ let dead = 0;
349
+ let degraded = 0;
350
+ for (const h of this.failure.health.values()) {
351
+ if (h === 'dead')
352
+ dead++;
353
+ else if (h === 'degraded')
354
+ degraded++;
355
+ }
356
+ return {
357
+ totalRps: total,
358
+ wastedRps: wasted,
359
+ wastedFraction: total > 0 ? wasted / total : 0,
360
+ nodes: this.scene.nodes.length,
361
+ edges: this.scene.edges.length,
362
+ records: this.matched,
363
+ filtered: this.filtered,
364
+ deadRps: this.failure.deadRps,
365
+ degradedRps: this.failure.degradedRps,
366
+ deadNodes: dead,
367
+ degradedNodes: degraded,
368
+ };
369
+ }
370
+ start() {
371
+ if (this.running)
372
+ return;
373
+ this.running = true;
374
+ this.lastFrame = now();
375
+ this.raf = requestAnimationFrame(this.frame);
376
+ }
377
+ stop() {
378
+ this.running = false;
379
+ if (this.raf)
380
+ cancelAnimationFrame(this.raf);
381
+ this.raf = 0;
382
+ }
383
+ toDataURL(type = 'image/png') {
384
+ return this.canvas.toDataURL(type);
385
+ }
386
+ destroy() {
387
+ this.stop();
388
+ this.resizeObserver?.disconnect();
389
+ this.motionQuery?.removeEventListener?.('change', this.onMotionChange);
390
+ this.themeQuery?.removeEventListener?.('change', this.onThemeChange);
391
+ this.canvas.removeEventListener('pointerdown', this.onPointerDown);
392
+ this.canvas.removeEventListener('pointerup', this.onPointerUp);
393
+ this.canvas.removeEventListener('pointermove', this.onPointerMove);
394
+ this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
395
+ this.canvas.removeEventListener('click', this.onClick);
396
+ this.canvas.remove();
397
+ this.tooltip.remove();
398
+ }
399
+ // --- projection and morph ------------------------------------------------
400
+ reproject() {
401
+ if (!this.table)
402
+ return;
403
+ const result = project(this.table, this.spec);
404
+ this.splitBy = result.splitBy;
405
+ this.applyScene(result.scene, this.spec, result.matched, result.filtered);
406
+ }
407
+ applyScene(scene, spec, matched, filtered) {
408
+ const previousScene = this.scene;
409
+ const previousPositions = new Map(this.positions);
410
+ const previousViews = this.views;
411
+ this.scene = scene;
412
+ this.spec = spec;
413
+ this.matched = matched;
414
+ this.filtered = filtered;
415
+ this.styles = styleScene(scene, spec, this.theme, this.encoding);
416
+ if (this.selected && !scene.nodes.some((n) => n.id === this.selected))
417
+ this.selected = null;
418
+ // A column binding overrides inferred layering.
419
+ for (const node of scene.nodes) {
420
+ const col = this.styles.nodes.get(node.id)?.column;
421
+ if (col !== null && col !== undefined)
422
+ node.layer = col;
423
+ else if (!scene.nodeKey.length) {
424
+ /* keep whatever the flat graph declared */
425
+ }
426
+ else
427
+ delete node.layer;
428
+ }
429
+ const couplingBound = Boolean(spec.channels?.coupling);
430
+ this.failure = spec.killed?.length
431
+ ? propagateFailure(scene, spec.killed, this.styles.edges, couplingBound)
432
+ : emptyFailure(scene);
433
+ this.layout(previousScene, previousPositions, previousViews);
434
+ this.opts.onView?.({
435
+ scene,
436
+ spec,
437
+ legend: this.styles.legend,
438
+ splitBy: this.splitBy,
439
+ issues: this.styles.issues,
440
+ stats: this.stats(),
441
+ });
442
+ }
443
+ layout(previousScene, previousPositions, previousViews) {
444
+ if (!this.scene.nodes.length) {
445
+ this.views = [];
446
+ this.positions = new Map();
447
+ this.targets = new Map();
448
+ this.draw();
449
+ return;
450
+ }
451
+ if (this.isWorld()) {
452
+ const box = this.box();
453
+ this.world = layoutWorld(this.scene, {
454
+ tierOf: (n) => this.tierOf(n),
455
+ attractionOf: (id) => this.styles.edges.get(id)?.attraction ?? 0.5,
456
+ sizeOf: (n) => this.bodySize(n),
457
+ viewport: { width: box.width, height: box.height, yaw: this.spec.world?.yaw ?? this.yaw },
458
+ ...(this.styles.clusterBy
459
+ ? { groupOf: (n) => nodeDimValue(n, this.styles.clusterBy) }
460
+ : {}),
461
+ seed: this.opts.seed,
462
+ });
463
+ this.yaw = this.spec.world?.yaw ?? this.yaw;
464
+ this.targets = this.projectWorld();
465
+ }
466
+ else {
467
+ this.world = new Map();
468
+ this.camera = null;
469
+ const box = this.box();
470
+ const laid = layoutGraph(this.scene, {
471
+ ...defaultLayout,
472
+ ...this.layoutOpts,
473
+ width: box.width,
474
+ height: box.height,
475
+ weightOf: (id) => this.styles.edges.get(id)?.attraction ?? 0.5,
476
+ ...(this.rowKey() ? { rowKey: this.rowKey() } : {}),
477
+ });
478
+ this.targets = new Map();
479
+ for (const [id, n] of laid)
480
+ this.targets.set(id, { ...n, x: n.x + box.left, y: n.y + box.top });
481
+ }
482
+ // Where each node comes from, so a re-projection reads as one system being
483
+ // re-viewed rather than four unrelated diagrams. A node that merged several
484
+ // predecessors starts at their centroid; one that split starts at its
485
+ // parent; one with no ancestor fades in where it lands.
486
+ const ancestors = ancestorMap(previousScene, this.scene);
487
+ this.origins = new Map();
488
+ for (const node of this.scene.nodes) {
489
+ const target = this.targets.get(node.id);
490
+ const from = ancestors.get(node.id) ?? [];
491
+ const points = from.map((id) => previousPositions.get(id)).filter(Boolean);
492
+ if (points.length) {
493
+ this.origins.set(node.id, {
494
+ x: points.reduce((a, p) => a + p.x, 0) / points.length,
495
+ y: points.reduce((a, p) => a + p.y, 0) / points.length,
496
+ fade: 1,
497
+ });
498
+ }
499
+ else {
500
+ this.origins.set(node.id, { x: target.x, y: target.y, fade: 0 });
501
+ }
502
+ }
503
+ const shouldMorph = this.opts.morphMs > 0 && previousPositions.size > 0 && [...ancestors.values()].some((a) => a.length);
504
+ this.morphing = shouldMorph;
505
+ this.morphFrom = now();
506
+ this.positions = shouldMorph ? interpolate(this.origins, this.targets, 0) : new Map(this.targets);
507
+ this.buildViews(previousScene, previousViews);
508
+ if (!shouldMorph)
509
+ this.placeLabels();
510
+ this.draw();
511
+ if (!this.running)
512
+ this.start();
513
+ }
514
+ buildViews(previousScene, previousViews) {
515
+ const domain = rpsDomain(this.scene.edges.map((e) => e.metrics.rps));
516
+ const offsets = laneOffsets(this.scene.edges, this.styles.edges);
517
+ const adopted = adoptStreams(previousScene, this.scene, previousViews);
518
+ this.views = this.scene.edges.map((edge) => {
519
+ const style = this.styles.edges.get(edge.id);
520
+ const stream = adopted.get(edge.id) ?? new EdgeStream(asMetricEdge(edge), this.rand);
521
+ stream.configureDirect(style.density, style.speed, style.radius, edge.metrics.errorRate);
522
+ const lane = offsets.get(edge.id) ?? { offset: 0, spread: 4.2, phase: 0 };
523
+ stream.setLane(lane.spread, lane.phase);
524
+ const offset = lane.offset;
525
+ return {
526
+ edge,
527
+ style,
528
+ stream,
529
+ offset,
530
+ spread: lane.spread,
531
+ width: edgeWidth(edge.metrics.rps, domain, { curve: this.encoding.densityCurve }),
532
+ path: this.pathFor(edge, offset),
533
+ labelT: 0.5,
534
+ };
535
+ });
536
+ }
537
+ isWorld() {
538
+ return this.spec.mode === 'world';
539
+ }
540
+ /** Which plane a node sits on: an explicit binding, else its kind. */
541
+ tierOf(node) {
542
+ const by = this.spec.world?.tierBy;
543
+ if (by) {
544
+ const value = nodeDimValue(node, by);
545
+ const order = this.spec.world?.tiers;
546
+ if (value && order?.length) {
547
+ const i = order.indexOf(value);
548
+ if (i >= 0)
549
+ return i;
550
+ }
551
+ if (value) {
552
+ // Stable order for values the spec did not list.
553
+ const all = [...new Set(this.scene.nodes.map((n) => nodeDimValue(n, by) ?? '∅'))].sort();
554
+ return all.indexOf(value);
555
+ }
556
+ }
557
+ return defaultTier(node);
558
+ }
559
+ /**
560
+ * How big a body is drawn. Mass, not area: a fleet of 64 should read as
561
+ * bigger than a single box without swallowing the plane, so it grows with the
562
+ * logarithm of the instance count.
563
+ */
564
+ bodySize(node) {
565
+ const field = this.spec.world?.instances ?? 'instances';
566
+ const instances = Math.max(1, node.attrs[field] ?? 1);
567
+ const base = defaultWorldLayout;
568
+ const crowd = this.crowdScale();
569
+ return {
570
+ w: Math.min(base.nodeWidth * (1 + Math.log2(instances) * 0.09), base.nodeWidth * 1.45) * crowd,
571
+ h: Math.min(base.nodeHeight * (1 + Math.log2(instances) * 0.04), base.nodeHeight * 1.3) * crowd,
572
+ };
573
+ }
574
+ /**
575
+ * Shrink every body when the projection puts more things on screen than the
576
+ * canvas can hold at full size. Expanding a service into forty APIs should
577
+ * give you smaller machines, not a pile of overlapping ones.
578
+ */
579
+ crowdScale() {
580
+ const count = this.scene.nodes.length;
581
+ if (!count)
582
+ return 1;
583
+ const box = this.box();
584
+ const usable = box.width * box.height * 0.5;
585
+ const footprint = defaultWorldLayout.nodeWidth * (defaultWorldLayout.nodeHeight + defaultWorldLayout.labelSpace);
586
+ return clamp(Math.sqrt(usable / (count * footprint)), 0.5, 1);
587
+ }
588
+ /** How rows are ordered inside a flat column, from the spec. */
589
+ rowKey() {
590
+ const mode = this.spec.layout?.rowSort ?? 'auto';
591
+ if (mode === 'auto')
592
+ return null;
593
+ const byId = new Map(this.scene.nodes.map((n) => [n.id, n]));
594
+ if (mode === 'name')
595
+ return (id) => byId.get(id)?.label ?? id;
596
+ return (id) => {
597
+ const n = byId.get(id);
598
+ return n ? n.inboundRps + n.outboundRps : 0;
599
+ };
600
+ }
601
+ /** The drawable box once insets and the legend strip are taken out. */
602
+ box() {
603
+ const legend = this.opts.showLegend ? LEGEND_STRIP : 0;
604
+ const { top, right, bottom, left } = this.insets;
605
+ return {
606
+ left,
607
+ top,
608
+ width: Math.max(this.width - left - right, 200),
609
+ height: Math.max(this.height - top - bottom - legend, 160),
610
+ };
611
+ }
612
+ /** Re-project after a camera change, keeping everything else in place. */
613
+ recamera() {
614
+ this.targets = this.projectWorld();
615
+ this.positions = new Map(this.targets);
616
+ for (const v of this.views)
617
+ v.path = this.pathFor(v.edge, v.offset);
618
+ }
619
+ /** Flatten the world onto the screen for this frame's camera. */
620
+ projectWorld() {
621
+ const box = this.box();
622
+ const camera = fitCamera(this.world, box.width, box.height, this.yaw, undefined, undefined, worldExtent(this.world));
623
+ camera.cx += box.left;
624
+ camera.cy += box.top;
625
+ this.camera = camera;
626
+ const out = new Map();
627
+ for (const [id, node] of this.world) {
628
+ const p = project3(node, camera);
629
+ out.set(id, { id, layer: node.tier, x: p.x, y: p.y, width: node.w, height: node.h });
630
+ }
631
+ return out;
632
+ }
633
+ pathFor(edge, offset) {
634
+ const from = this.positions.get(edge.from) ?? this.targets.get(edge.from);
635
+ const to = this.positions.get(edge.to) ?? this.targets.get(edge.to);
636
+ return edgePath(from, to, offset);
637
+ }
638
+ resize() {
639
+ const rect = this.container.getBoundingClientRect();
640
+ this.width = Math.max(rect.width || this.container.clientWidth || 800, 200);
641
+ this.height = Math.max(rect.height || this.container.clientHeight || 500, 160);
642
+ this.dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
643
+ this.canvas.width = Math.round(this.width * this.dpr);
644
+ this.canvas.height = Math.round(this.height * this.dpr);
645
+ const keep = this.opts.morphMs;
646
+ this.opts.morphMs = 0;
647
+ this.applyScene(this.scene, this.spec, this.matched, this.filtered);
648
+ this.opts.morphMs = keep;
649
+ }
650
+ /** Seconds after the kill at which the front reaches this edge. */
651
+ frontArrival(edge) {
652
+ const hop = Math.min(this.failure.hop.get(edge.to) ?? 0, this.failure.hop.get(edge.from) ?? 99);
653
+ return hop * FRONT_HOP_SECONDS;
654
+ }
655
+ // --- drawing -------------------------------------------------------------
656
+ draw() {
657
+ const { ctx, theme } = this;
658
+ ctx.save();
659
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
660
+ ctx.clearRect(0, 0, this.width, this.height);
661
+ ctx.fillStyle = theme.background;
662
+ ctx.fillRect(0, 0, this.width, this.height);
663
+ const focusNode = this.hovered?.kind === 'node' ? this.hovered.node.id : (this.selected ?? undefined);
664
+ const highlightId = focusNode;
665
+ const hoveredEdgeId = this.hovered?.kind === 'edge' ? this.hovered.edge.id : undefined;
666
+ const frontAge = (now() - this.killedAt) / 1000;
667
+ if (this.isWorld())
668
+ this.drawTiers();
669
+ else
670
+ this.drawGroups();
671
+ for (const v of this.views) {
672
+ this.drawEdge(v, this.isDimmed(v, focusNode, hoveredEdgeId), frontAge);
673
+ }
674
+ for (const v of this.views)
675
+ this.drawParticles(v, this.isDimmed(v, focusNode, hoveredEdgeId));
676
+ if (this.opts.showEdgeLabels && !this.morphing)
677
+ for (const v of this.views)
678
+ this.drawEdgeLabel(v);
679
+ if (this.isWorld()) {
680
+ // Painter's algorithm: far things first, so a rotated world stacks right.
681
+ const depth = new Map();
682
+ for (const [id, n] of this.world)
683
+ depth.set(id, this.camera ? project3(n, this.camera).depth : n.z);
684
+ const order = [...this.scene.nodes].sort((a, b) => (depth.get(a.id) ?? 0) - (depth.get(b.id) ?? 0));
685
+ for (const node of order)
686
+ this.drawWorldNode(node, highlightId, frontAge);
687
+ }
688
+ else {
689
+ for (const node of this.scene.nodes)
690
+ this.drawNode(node, highlightId, frontAge);
691
+ }
692
+ if (this.opts.showLegend)
693
+ this.drawLegend();
694
+ ctx.restore();
695
+ }
696
+ /** Is this edge pushed into the background by the current hover, selection or isolation? */
697
+ isDimmed(v, focusNode, hoveredEdgeId) {
698
+ if (hoveredEdgeId !== undefined)
699
+ return v.edge.id !== hoveredEdgeId;
700
+ if (focusNode !== undefined && v.edge.from !== focusNode && v.edge.to !== focusNode)
701
+ return true;
702
+ if (this.highlight) {
703
+ const value = v.edge.dims[this.highlight.field] ?? v.edge.spans[this.highlight.field]?.[0];
704
+ const spans = v.edge.spans[this.highlight.field];
705
+ const carries = value === this.highlight.value || (spans?.includes(this.highlight.value) ?? false);
706
+ if (!carries)
707
+ return true;
708
+ }
709
+ return false;
710
+ }
711
+ drawEdge(v, dim, frontAge) {
712
+ const { ctx, theme } = this;
713
+ const health = this.failure.edgeHealth.get(v.edge.id) ?? 'flowing';
714
+ const arrival = this.frontArrival(v.edge);
715
+ const cutProgress = health === 'severed' ? clamp01((frontAge - arrival) / FRONT_HOP_SECONDS) : 0;
716
+ const waste = WASTE_KINDS.has(v.edge.kind);
717
+ ctx.save();
718
+ ctx.globalAlpha = dim ? 0.2 : health === 'severed' ? 0.85 - cutProgress * 0.45 : 1;
719
+ const stroke = health === 'severed' ? theme.error : health === 'strained' ? theme.flow.retry : waste ? theme.edgeMuted : theme.edge;
720
+ ctx.strokeStyle = stroke;
721
+ // With the numbers off, throughput has to come through twice: as particle
722
+ // density, and as the thickness of the conduit carrying them.
723
+ ctx.lineWidth = v.width;
724
+ // Depth without a 3D engine: a wide, faint under-stroke in the flow's own
725
+ // hue sits beneath a crisp line, so the path reads as a lit conduit rather
726
+ // than a hairline lost against the ground. Heavier flows glow more.
727
+ if (!dim && health === 'flowing') {
728
+ ctx.save();
729
+ ctx.strokeStyle = v.style.hue;
730
+ ctx.lineWidth = v.width + 6;
731
+ ctx.globalAlpha = 0.07 + Math.min(v.width / 9, 1) * 0.13;
732
+ ctx.beginPath();
733
+ ctx.moveTo(v.path.a.x, v.path.a.y);
734
+ ctx.quadraticCurveTo(v.path.ctrl.x, v.path.ctrl.y, v.path.b.x, v.path.b.y);
735
+ ctx.stroke();
736
+ ctx.restore();
737
+ }
738
+ if (v.style.coupling === 'rigid')
739
+ this.strokeRigid(v, stroke);
740
+ else {
741
+ ctx.setLineDash(v.style.dash ?? []);
742
+ ctx.beginPath();
743
+ ctx.moveTo(v.path.a.x, v.path.a.y);
744
+ ctx.quadraticCurveTo(v.path.ctrl.x, v.path.ctrl.y, v.path.b.x, v.path.b.y);
745
+ ctx.stroke();
746
+ ctx.setLineDash([]);
747
+ }
748
+ if (v.style.coupling === 'breakaway')
749
+ this.drawBreakaway(v, health, cutProgress);
750
+ if (v.style.coupling === 'rigid' && cutProgress > 0)
751
+ this.drawFracture(v, cutProgress);
752
+ // Arrowhead
753
+ const tip = v.path.pointAt(0.985);
754
+ const dir = v.path.tangentAt(0.985);
755
+ const size = 6.5;
756
+ ctx.fillStyle = stroke;
757
+ ctx.beginPath();
758
+ ctx.moveTo(tip.x, tip.y);
759
+ ctx.lineTo(tip.x - dir.x * size - dir.y * size * 0.5, tip.y - dir.y * size + dir.x * size * 0.5);
760
+ ctx.lineTo(tip.x - dir.x * size + dir.y * size * 0.5, tip.y - dir.y * size - dir.x * size * 0.5);
761
+ ctx.closePath();
762
+ ctx.fill();
763
+ ctx.restore();
764
+ }
765
+ /**
766
+ * A rigid (fail-closed) coupling is drawn as a mechanical conduit: two
767
+ * parallel rails with cross-ties. It looks like something that transmits
768
+ * force, because that is exactly what it does - death travels along it.
769
+ */
770
+ strokeRigid(v, stroke) {
771
+ const { ctx } = this;
772
+ ctx.save();
773
+ ctx.strokeStyle = stroke;
774
+ ctx.lineWidth = 1.1;
775
+ for (const side of [-1.9, 1.9]) {
776
+ ctx.beginPath();
777
+ for (let i = 0; i <= 20; i++) {
778
+ const t = i / 20;
779
+ const p = v.path.pointAt(t);
780
+ const tan = v.path.tangentAt(t);
781
+ const x = p.x - tan.y * side;
782
+ const y = p.y + tan.x * side;
783
+ if (i === 0)
784
+ ctx.moveTo(x, y);
785
+ else
786
+ ctx.lineTo(x, y);
787
+ }
788
+ ctx.stroke();
789
+ }
790
+ // Cross-ties: the visual signature of a hard coupling.
791
+ for (const t of [0.24, 0.5, 0.76]) {
792
+ const p = v.path.pointAt(t);
793
+ const tan = v.path.tangentAt(t);
794
+ ctx.beginPath();
795
+ ctx.moveTo(p.x - tan.y * 3.4, p.y + tan.x * 3.4);
796
+ ctx.lineTo(p.x + tan.y * 3.4, p.y - tan.x * 3.4);
797
+ ctx.stroke();
798
+ }
799
+ ctx.restore();
800
+ }
801
+ /**
802
+ * A breakaway (fail-open) coupling has a deliberate parting line: a gap with
803
+ * two facing chevrons. When the far side dies it opens, and an amber shield
804
+ * arc marks where the front stopped. A fail-open dependency is a firebreak
805
+ * and the picture should say so.
806
+ */
807
+ drawBreakaway(v, health, cut) {
808
+ const { ctx, theme } = this;
809
+ const open = health === 'severed' ? cut : 0;
810
+ const gap = 5 + open * 9;
811
+ const mid = v.path.pointAt(0.5);
812
+ const tan = v.path.tangentAt(0.5);
813
+ const nx = -tan.y;
814
+ const ny = tan.x;
815
+ ctx.save();
816
+ ctx.strokeStyle = open > 0 ? theme.flow.retry : theme.edge;
817
+ ctx.lineWidth = 1.6;
818
+ for (const dir of [-1, 1]) {
819
+ const cx = mid.x + tan.x * gap * dir;
820
+ const cy = mid.y + tan.y * gap * dir;
821
+ ctx.beginPath();
822
+ ctx.moveTo(cx + nx * 4.2 - tan.x * 3 * dir, cy + ny * 4.2 - tan.y * 3 * dir);
823
+ ctx.lineTo(cx, cy);
824
+ ctx.lineTo(cx - nx * 4.2 - tan.x * 3 * dir, cy - ny * 4.2 - tan.y * 3 * dir);
825
+ ctx.stroke();
826
+ }
827
+ if (open > 0.15) {
828
+ // Shield arc on the surviving (caller) side.
829
+ const at = v.path.pointAt(0.34);
830
+ ctx.strokeStyle = theme.flow.retry;
831
+ ctx.lineWidth = 2;
832
+ ctx.globalAlpha = Math.min(1, (open - 0.15) * 2.4);
833
+ ctx.beginPath();
834
+ ctx.arc(at.x, at.y, 9, Math.atan2(ny, nx) - 1.05, Math.atan2(ny, nx) + 1.05);
835
+ ctx.stroke();
836
+ }
837
+ ctx.restore();
838
+ }
839
+ /** Where a rigid coupling gives way, it fractures rather than fading. */
840
+ drawFracture(v, progress) {
841
+ const { ctx, theme } = this;
842
+ const at = v.path.pointAt(0.5);
843
+ const tan = v.path.tangentAt(0.5);
844
+ const nx = -tan.y;
845
+ const ny = tan.x;
846
+ const size = 4 + progress * 7;
847
+ ctx.save();
848
+ ctx.strokeStyle = theme.error;
849
+ ctx.lineWidth = 2;
850
+ ctx.lineCap = 'round';
851
+ ctx.beginPath();
852
+ ctx.moveTo(at.x + nx * size, at.y + ny * size);
853
+ ctx.lineTo(at.x + tan.x * size * 0.5 - nx * size * 0.25, at.y + tan.y * size * 0.5 - ny * size * 0.25);
854
+ ctx.lineTo(at.x - tan.x * size * 0.5 + nx * size * 0.25, at.y - tan.y * size * 0.5 + ny * size * 0.25);
855
+ ctx.lineTo(at.x - nx * size, at.y - ny * size);
856
+ ctx.stroke();
857
+ ctx.restore();
858
+ }
859
+ drawParticles(v, dim = false) {
860
+ if (!this.opts.animate)
861
+ return;
862
+ const { ctx, theme } = this;
863
+ const health = this.failure.edgeHealth.get(v.edge.id) ?? 'flowing';
864
+ const base = health === 'strained' ? theme.flow.retry : v.style.hue;
865
+ const glow = v.style.glyph === 'dot' ? this.sprite(base) : null;
866
+ ctx.save();
867
+ for (const p of v.stream.particles) {
868
+ if (p.t < 0)
869
+ continue;
870
+ const pt = v.path.pointAt(p.t);
871
+ const tan = v.path.tangentAt(p.t);
872
+ const x = pt.x - tan.y * p.wobble;
873
+ const y = pt.y + tan.x * p.wobble;
874
+ const fade = Math.min(1, p.t * 14, (1 - p.t) * 14) * (dim ? 0.18 : 1);
875
+ if (p.error) {
876
+ ctx.globalAlpha = fade;
877
+ ctx.strokeStyle = theme.error;
878
+ ctx.lineWidth = 1.4;
879
+ ctx.beginPath();
880
+ ctx.arc(x, y, p.radius + 1.1, 0, Math.PI * 2);
881
+ ctx.stroke();
882
+ }
883
+ else if (glow) {
884
+ // A soft-edged sprite with a short tail behind it along the path. The
885
+ // tail is what makes these read as things in motion rather than dots
886
+ // that happen to be somewhere else next frame; its length is the
887
+ // particle's own speed, so a fast hop streaks and a slow one crawls.
888
+ const size = p.radius * 2.9;
889
+ const stepT = (p.speed * 0.04) / v.path.length;
890
+ for (let k = 3; k >= 1; k--) {
891
+ const tt = p.t - stepT * k;
892
+ if (tt <= 0)
893
+ continue;
894
+ const q = v.path.pointAt(tt);
895
+ const tq = v.path.tangentAt(tt);
896
+ const s = size * (1 - k * 0.2);
897
+ ctx.globalAlpha = fade * (v.spread > 6 ? 0.9 : 1) * (0.5 - k * 0.13);
898
+ ctx.drawImage(glow, q.x - tq.y * p.wobble - s / 2, q.y + tq.x * p.wobble - s / 2, s, s);
899
+ }
900
+ ctx.globalAlpha = fade * (v.spread > 6 ? 0.9 : 1);
901
+ ctx.drawImage(glow, x - size / 2, y - size / 2, size, size);
902
+ }
903
+ else {
904
+ ctx.globalAlpha = fade * (v.spread > 6 ? 0.82 : 0.92);
905
+ drawGlyph(ctx, v.style.glyph, x, y, p.radius, base, tan);
906
+ }
907
+ }
908
+ ctx.restore();
909
+ }
910
+ /** A cached radial sprite per colour: bright core, soft halo. */
911
+ sprite(color) {
912
+ let c = this.sprites.get(color);
913
+ if (c)
914
+ return c;
915
+ c = document.createElement('canvas');
916
+ const S = 32;
917
+ c.width = S;
918
+ c.height = S;
919
+ const g = c.getContext('2d');
920
+ // A solid core with a short anti-aliased edge - a point of light, not a
921
+ // smear. The earlier wide bloom read as out of focus.
922
+ const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
923
+ grad.addColorStop(0, color);
924
+ grad.addColorStop(0.62, color);
925
+ grad.addColorStop(0.8, withAlpha(color, 0.35));
926
+ grad.addColorStop(1, withAlpha(color, 0));
927
+ g.fillStyle = grad;
928
+ g.fillRect(0, 0, S, S);
929
+ this.sprites.set(color, c);
930
+ return c;
931
+ }
932
+ drawEdgeLabel(v) {
933
+ if (v.labelT === null)
934
+ return;
935
+ const { ctx, theme } = this;
936
+ const text = edgeLabelText(v.edge);
937
+ const at = v.path.pointAt(v.labelT);
938
+ ctx.save();
939
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
940
+ ctx.textAlign = 'center';
941
+ ctx.textBaseline = 'middle';
942
+ const w = ctx.measureText(text).width + 8;
943
+ ctx.globalAlpha = 0.94;
944
+ ctx.fillStyle = theme.background;
945
+ roundRect(ctx, at.x - w / 2, at.y - 8, w, 16, 4);
946
+ ctx.fill();
947
+ ctx.globalAlpha = 1;
948
+ ctx.fillStyle = WASTE_KINDS.has(v.edge.kind) ? theme.flow.retry : theme.textMuted;
949
+ ctx.fillText(text, at.x, at.y);
950
+ ctx.restore();
951
+ }
952
+ drawNode(node, highlightId, frontAge) {
953
+ const pos = this.positions.get(node.id);
954
+ if (!pos)
955
+ return;
956
+ const { ctx, theme } = this;
957
+ const style = this.styles.nodes.get(node.id) ?? { fill: theme.node[node.kind], halo: 0, haloDanger: false, haloIntensity: 0, column: null, group: null };
958
+ const health = this.failure.health.get(node.id) ?? 'ok';
959
+ const arrival = (this.failure.hop.get(node.id) ?? 0) * FRONT_HOP_SECONDS;
960
+ const struck = health !== 'ok' ? clamp01((frontAge - arrival) / FRONT_HOP_SECONDS) : 0;
961
+ const fade = this.origins.get(node.id)?.fade ?? 1;
962
+ const morphFade = this.morphing && fade === 0 ? clamp01((now() - this.morphFrom) / this.opts.morphMs) : 1;
963
+ const x = pos.x - pos.width / 2;
964
+ const y = pos.y - pos.height / 2;
965
+ ctx.save();
966
+ ctx.globalAlpha = (highlightId && highlightId !== node.id ? 0.55 : 1) * morphFade;
967
+ const baseAlpha = (highlightId && highlightId !== node.id ? 0.55 : 1) * morphFade;
968
+ if (style.haloDanger && style.haloIntensity > 0) {
969
+ this.drawDangerGlow(x, y, pos.width, pos.height, style.haloIntensity, baseAlpha);
970
+ }
971
+ else if (style.halo > 0) {
972
+ // Keep the ring inside the row gap on a crowded column, or halos merge
973
+ // into one outline around the whole column and say nothing.
974
+ const halo = Math.min(style.halo, Math.max(2, pos.height * 0.28));
975
+ ctx.strokeStyle = theme.border;
976
+ ctx.lineWidth = 1 + (style.halo / 16) * 1.5;
977
+ ctx.globalAlpha *= 0.55;
978
+ roundRect(ctx, x - halo, y - halo, pos.width + halo * 2, pos.height + halo * 2, 12);
979
+ ctx.stroke();
980
+ ctx.globalAlpha = baseAlpha;
981
+ }
982
+ ctx.fillStyle = style.fill;
983
+ ctx.strokeStyle = highlightId === node.id ? theme.highlight : theme.border;
984
+ ctx.lineWidth = highlightId === node.id ? 1.8 : 1;
985
+ roundRect(ctx, x, y, pos.width, pos.height, 8);
986
+ ctx.fill();
987
+ ctx.stroke();
988
+ this.drawSelectionRing(node.id, pos.x, pos.y, pos.width + 10, pos.height + 10, 12);
989
+ this.drawShock(node.id, pos.x, pos.y);
990
+ if (health === 'dead' && struck > 0) {
991
+ // Hatch a dead node rather than just greying it: the texture survives a
992
+ // screenshot pasted into a chat, where a colour shift does not.
993
+ ctx.save();
994
+ roundRect(ctx, x, y, pos.width, pos.height, 8);
995
+ ctx.clip();
996
+ ctx.globalAlpha = struck * 0.9;
997
+ ctx.fillStyle = theme.background;
998
+ ctx.fillRect(x, y, pos.width, pos.height);
999
+ ctx.strokeStyle = theme.error;
1000
+ ctx.lineWidth = 1;
1001
+ ctx.globalAlpha = struck * 0.55;
1002
+ for (let i = -pos.height; i < pos.width; i += 6) {
1003
+ ctx.beginPath();
1004
+ ctx.moveTo(x + i, y + pos.height);
1005
+ ctx.lineTo(x + i + pos.height, y);
1006
+ ctx.stroke();
1007
+ }
1008
+ ctx.restore();
1009
+ ctx.strokeStyle = theme.error;
1010
+ ctx.lineWidth = 1.6;
1011
+ roundRect(ctx, x, y, pos.width, pos.height, 8);
1012
+ ctx.stroke();
1013
+ }
1014
+ else if (health === 'degraded' && struck > 0) {
1015
+ ctx.save();
1016
+ ctx.strokeStyle = theme.flow.retry;
1017
+ ctx.lineWidth = 2;
1018
+ ctx.setLineDash([4, 4]);
1019
+ ctx.globalAlpha = struck * (0.6 + 0.4 * Math.sin(frontAge * 4));
1020
+ roundRect(ctx, x - 4, y - 4, pos.width + 8, pos.height + 8, 11);
1021
+ ctx.stroke();
1022
+ ctx.restore();
1023
+ }
1024
+ ctx.fillStyle = health === 'dead' ? theme.textMuted : theme.nodeText;
1025
+ ctx.font = LABEL_FONT(12);
1026
+ ctx.textAlign = 'center';
1027
+ ctx.textBaseline = 'middle';
1028
+ // A dense projection makes the boxes narrow, and the name is the one thing
1029
+ // on a box that cannot be inferred from anything else - not from the fill,
1030
+ // not from the shape. So it never gets truncated to "inven…": it breaks at
1031
+ // a separator if there is one, otherwise it shrinks. Breaking mid-word is
1032
+ // the one outcome worse than either.
1033
+ const room = pos.width - 12;
1034
+ const short = pos.height < 36;
1035
+ if (short) {
1036
+ // A compact bar: name only, centred. Everything else is on hover.
1037
+ ctx.font = LABEL_FONT(pos.height < 28 ? 10 : 11);
1038
+ ctx.fillText(ellipsis(ctx, node.label, room), pos.x, pos.y);
1039
+ ctx.restore();
1040
+ return;
1041
+ }
1042
+ const lines = fitLabel(ctx, node.label, room);
1043
+ if (lines.text.length > 1) {
1044
+ ctx.fillText(lines.text[0], pos.x, pos.y - 8);
1045
+ ctx.fillText(lines.text[1], pos.x, pos.y + 5);
1046
+ }
1047
+ else {
1048
+ ctx.fillText(lines.text[0], pos.x, pos.y - 5);
1049
+ ctx.fillStyle = theme.textMuted;
1050
+ ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
1051
+ ctx.fillText(ellipsis(ctx, node.sublabel ?? node.kind, room), pos.x, pos.y + 10);
1052
+ }
1053
+ ctx.restore();
1054
+ }
1055
+ /**
1056
+ * A neighbourhood per group. When boxes are arranged by a dimension (a team,
1057
+ * a tier), the boxes sharing a value get a soft region behind them with the
1058
+ * value written at the top, so "these live together" is drawn, not inferred
1059
+ * from alignment.
1060
+ */
1061
+ drawGroups() {
1062
+ const { ctx, theme } = this;
1063
+ const groups = new Map();
1064
+ for (const node of this.scene.nodes) {
1065
+ const g = this.styles.nodes.get(node.id)?.group;
1066
+ const p = this.positions.get(node.id);
1067
+ if (!g || !p)
1068
+ continue;
1069
+ const b = groups.get(g) ?? { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
1070
+ b.minX = Math.min(b.minX, p.x - p.width / 2);
1071
+ b.maxX = Math.max(b.maxX, p.x + p.width / 2);
1072
+ b.minY = Math.min(b.minY, p.y - p.height / 2);
1073
+ b.maxY = Math.max(b.maxY, p.y + p.height / 2);
1074
+ groups.set(g, b);
1075
+ }
1076
+ if (groups.size < 2)
1077
+ return;
1078
+ ctx.save();
1079
+ for (const [name, b] of groups) {
1080
+ const pad = 14;
1081
+ const x = b.minX - pad;
1082
+ const y = b.minY - pad - 16;
1083
+ const w = b.maxX - b.minX + pad * 2;
1084
+ const h = b.maxY - b.minY + pad * 2 + 16;
1085
+ ctx.globalAlpha = 1;
1086
+ ctx.fillStyle = theme.grid;
1087
+ ctx.strokeStyle = theme.border;
1088
+ ctx.lineWidth = 1;
1089
+ roundRect(ctx, x, y, w, h, 14);
1090
+ ctx.fill();
1091
+ ctx.stroke();
1092
+ ctx.fillStyle = theme.textMuted;
1093
+ ctx.font = '500 10px ui-monospace, SFMono-Regular, Menlo, monospace';
1094
+ ctx.textAlign = 'left';
1095
+ ctx.textBaseline = 'top';
1096
+ ctx.fillText(name.toUpperCase(), x + 12, y + 6);
1097
+ }
1098
+ ctx.restore();
1099
+ }
1100
+ /**
1101
+ * The planes themselves. Drawing the ground a tier sits on is what turns a
1102
+ * scatter of boxes into somewhere - and it makes "the data tier" a place you
1103
+ * can point at rather than a convention you have to remember.
1104
+ */
1105
+ drawTiers() {
1106
+ if (!this.camera)
1107
+ return;
1108
+ const { ctx, theme } = this;
1109
+ const tiers = new Map();
1110
+ for (const n of this.world.values())
1111
+ tiers.set(n.tier, n.y);
1112
+ const ordered = [...tiers.entries()].sort((a, b) => a[1] - b[1]);
1113
+ ctx.save();
1114
+ for (const [tier, y] of ordered) {
1115
+ const e = worldExtent(this.world);
1116
+ const corners = [
1117
+ { x: -e, y, z: -e },
1118
+ { x: e, y, z: -e },
1119
+ { x: e, y, z: e },
1120
+ { x: -e, y, z: e },
1121
+ ].map((c) => project3(c, this.camera));
1122
+ ctx.beginPath();
1123
+ ctx.moveTo(corners[0].x, corners[0].y);
1124
+ for (const c of corners.slice(1))
1125
+ ctx.lineTo(c.x, c.y);
1126
+ ctx.closePath();
1127
+ ctx.globalAlpha = 0.5;
1128
+ ctx.fillStyle = theme.grid;
1129
+ ctx.fill();
1130
+ ctx.globalAlpha = 0.85;
1131
+ ctx.strokeStyle = theme.border;
1132
+ ctx.lineWidth = 1;
1133
+ ctx.stroke();
1134
+ ctx.globalAlpha = 1;
1135
+ ctx.fillStyle = theme.textMuted;
1136
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
1137
+ ctx.textAlign = 'left';
1138
+ ctx.textBaseline = 'middle';
1139
+ const label = this.tierLabel(tier);
1140
+ const anchor = corners[3];
1141
+ ctx.fillText(label, anchor.x + 6, anchor.y - 8);
1142
+ }
1143
+ ctx.restore();
1144
+ }
1145
+ tierLabel(tier) {
1146
+ const by = this.spec.world?.tierBy;
1147
+ if (by) {
1148
+ const order = this.spec.world?.tiers ??
1149
+ [...new Set(this.scene.nodes.map((n) => nodeDimValue(n, by) ?? '∅'))].sort();
1150
+ return order[tier] ?? `${by} ${tier}`;
1151
+ }
1152
+ return TIER_LABELS[tier] ?? `tier ${tier}`;
1153
+ }
1154
+ drawWorldNode(node, highlightId, frontAge) {
1155
+ const pos = this.positions.get(node.id);
1156
+ if (!pos)
1157
+ return;
1158
+ const { ctx, theme } = this;
1159
+ const style = this.styles.nodes.get(node.id) ?? { fill: theme.node[node.kind], halo: 0, haloDanger: false, haloIntensity: 0, column: null, group: null };
1160
+ const health = this.failure.health.get(node.id) ?? 'ok';
1161
+ const arrival = (this.failure.hop.get(node.id) ?? 0) * FRONT_HOP_SECONDS;
1162
+ const struck = health !== 'ok' ? clamp01((frontAge - arrival) / FRONT_HOP_SECONDS) : 0;
1163
+ const gaugeFields = this.spec.world?.gauges ?? ['cpu', 'mem'];
1164
+ const instanceField = this.spec.world?.instances ?? 'instances';
1165
+ const props = {
1166
+ ...componentProps(theme),
1167
+ instances: Math.max(1, node.attrs[instanceField] ?? 1),
1168
+ gauges: gaugeFields
1169
+ .filter((f) => node.attrs[f] !== undefined)
1170
+ .map((f) => ({ label: f, value: node.attrs[f] })),
1171
+ fill: style.fill,
1172
+ health,
1173
+ struck,
1174
+ };
1175
+ const component = COMPONENTS[pickComponent(node, this.componentHint(node))] ?? COMPONENTS['service-rack'];
1176
+ const box = { cx: pos.x, top: pos.y - pos.height / 2, w: pos.width, h: pos.height };
1177
+ ctx.save();
1178
+ ctx.globalAlpha = highlightId && highlightId !== node.id ? 0.45 : 1;
1179
+ // Contact shadow: without it the bodies float off their plane.
1180
+ ctx.globalAlpha *= 0.5;
1181
+ ctx.fillStyle = theme.background;
1182
+ ctx.beginPath();
1183
+ ctx.ellipse(pos.x, box.top + box.h + 2, pos.width * 0.42, 4, 0, 0, Math.PI * 2);
1184
+ ctx.fill();
1185
+ ctx.globalAlpha = highlightId && highlightId !== node.id ? 0.45 : 1;
1186
+ component.draw(ctx, box, props);
1187
+ this.drawSelectionRing(node.id, pos.x, box.top + box.h * 0.5, pos.width + 16, box.h + 16, 14);
1188
+ this.drawShock(node.id, pos.x, box.top + box.h * 0.6);
1189
+ if (health === 'dead' && struck > 0) {
1190
+ ctx.save();
1191
+ ctx.globalAlpha = struck * 0.6;
1192
+ ctx.strokeStyle = theme.error;
1193
+ ctx.lineWidth = 1.4;
1194
+ for (let i = -pos.height; i < pos.width; i += 6) {
1195
+ ctx.beginPath();
1196
+ ctx.moveTo(pos.x - pos.width / 2 + i, box.top + box.h);
1197
+ ctx.lineTo(pos.x - pos.width / 2 + i + box.h, box.top);
1198
+ ctx.stroke();
1199
+ }
1200
+ ctx.restore();
1201
+ }
1202
+ else if (health === 'degraded' && struck > 0) {
1203
+ ctx.save();
1204
+ ctx.globalAlpha = struck * (0.55 + 0.45 * Math.sin(frontAge * 3));
1205
+ ctx.strokeStyle = theme.flow.retry;
1206
+ ctx.lineWidth = 2;
1207
+ ctx.setLineDash([4, 4]);
1208
+ ctx.beginPath();
1209
+ ctx.ellipse(pos.x, box.top + box.h * 0.55, pos.width * 0.62, box.h * 0.66, 0, 0, Math.PI * 2);
1210
+ ctx.stroke();
1211
+ ctx.restore();
1212
+ }
1213
+ ctx.globalAlpha = highlightId && highlightId !== node.id ? 0.45 : 1;
1214
+ ctx.textAlign = 'center';
1215
+ ctx.textBaseline = 'top';
1216
+ ctx.fillStyle = health === 'dead' ? theme.textMuted : theme.nodeText;
1217
+ ctx.font = '600 11px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif';
1218
+ ctx.fillText(ellipsis(ctx, node.label, pos.width * 1.55), pos.x, box.top + box.h + 6);
1219
+ if (node.sublabel || props.instances > 1) {
1220
+ ctx.fillStyle = theme.textMuted;
1221
+ ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
1222
+ const sub = [node.sublabel, props.instances > 1 ? `×${Math.round(props.instances)}` : null]
1223
+ .filter(Boolean)
1224
+ .join(' · ');
1225
+ ctx.fillText(ellipsis(ctx, sub, pos.width * 1.55), pos.x, box.top + box.h + 18);
1226
+ }
1227
+ ctx.restore();
1228
+ void gaugeSpace;
1229
+ }
1230
+ componentHint(node) {
1231
+ const by = this.spec.world?.componentBy;
1232
+ if (node.component)
1233
+ return node.component;
1234
+ if (!by)
1235
+ return undefined;
1236
+ const value = nodeDimValue(node, by);
1237
+ if (!value)
1238
+ return undefined;
1239
+ return this.spec.world?.componentMap?.[value] ?? value;
1240
+ }
1241
+ /**
1242
+ * The error halo: a red glow that scales with how bad things are. Low
1243
+ * intensity is a thin warm rim you notice on a second look; high intensity
1244
+ * is a wide bloom that breathes slowly, so a burning box is the thing you
1245
+ * see from across the room without anything flashing at you.
1246
+ */
1247
+ drawDangerGlow(x, y, w, h, intensity, baseAlpha) {
1248
+ const { ctx, theme } = this;
1249
+ const t = clamp01(intensity);
1250
+ // Breathe only once it is serious; period ~3.4s, small amplitude, so the
1251
+ // motion reads as a pulse, not a blink. Phase by position so a column of
1252
+ // sick boxes does not throb in lockstep.
1253
+ const breathe = t > 0.5 ? (0.5 + 0.5 * Math.sin(now() / 540 + x * 0.013 + y * 0.007)) * (t - 0.5) * 2 : 0;
1254
+ const dark = theme.name === 'dark';
1255
+ const spread = 3 + t * t * 26 + breathe * 5;
1256
+ const strength = (0.18 + t * 0.62 + breathe * 0.12) * baseAlpha;
1257
+ ctx.save();
1258
+ // Bloom: a few blurred fills of shrinking size stack into a soft falloff
1259
+ // that stays crisp at the box edge, unlike one huge blur.
1260
+ ctx.fillStyle = theme.error;
1261
+ for (const [k, a] of [[1, 0.22], [0.6, 0.3], [0.3, 0.42]]) {
1262
+ ctx.shadowColor = theme.error;
1263
+ ctx.shadowBlur = spread * k * 2.2;
1264
+ ctx.globalAlpha = strength * a * (dark ? 1 : 0.8);
1265
+ const pad = spread * k * 0.5;
1266
+ roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, 10 + pad);
1267
+ ctx.fill();
1268
+ }
1269
+ ctx.shadowBlur = 0;
1270
+ // Rim: the definite edge of the halo, so intensity can also be read as a
1271
+ // width when the bloom is faint.
1272
+ ctx.strokeStyle = theme.error;
1273
+ ctx.lineWidth = 1 + t * t * 3.5;
1274
+ ctx.globalAlpha = (0.4 + t * 0.55) * baseAlpha;
1275
+ const rim = 2 + t * t * 5;
1276
+ roundRect(ctx, x - rim, y - rim, w + rim * 2, h + rim * 2, 10);
1277
+ ctx.stroke();
1278
+ ctx.restore();
1279
+ }
1280
+ /** A steady ring around the selected thing, with a slow breathing halo. */
1281
+ drawSelectionRing(id, cx, cy, w, h, r) {
1282
+ if (this.selected !== id)
1283
+ return;
1284
+ const { ctx, theme } = this;
1285
+ const breathe = 0.5 + 0.5 * Math.sin(now() / 700);
1286
+ ctx.save();
1287
+ ctx.strokeStyle = theme.highlight;
1288
+ ctx.lineWidth = 1.5;
1289
+ ctx.globalAlpha = 0.9;
1290
+ roundRect(ctx, cx - w / 2, cy - h / 2, w, h, r);
1291
+ ctx.stroke();
1292
+ ctx.globalAlpha = 0.18 + breathe * 0.14;
1293
+ ctx.lineWidth = 6;
1294
+ roundRect(ctx, cx - w / 2 - 3, cy - h / 2 - 3, w + 6, h + 6, r + 3);
1295
+ ctx.stroke();
1296
+ ctx.restore();
1297
+ }
1298
+ /** The moment of a kill: one expanding ring, gone in a second and a half. */
1299
+ drawShock(id, cx, cy) {
1300
+ const { ctx, theme } = this;
1301
+ for (const sh of this.shocks) {
1302
+ if (sh.id !== id)
1303
+ continue;
1304
+ const t = (now() - sh.at) / 1600;
1305
+ const r = 12 + t * 150;
1306
+ ctx.save();
1307
+ ctx.strokeStyle = theme.error;
1308
+ ctx.lineWidth = 2.2 * (1 - t);
1309
+ ctx.globalAlpha = (1 - t) * 0.8;
1310
+ ctx.beginPath();
1311
+ ctx.ellipse(cx, cy, r, r * (this.isWorld() ? 0.42 : 1), 0, 0, Math.PI * 2);
1312
+ ctx.stroke();
1313
+ ctx.restore();
1314
+ }
1315
+ }
1316
+ drawLegend() {
1317
+ const { ctx, theme } = this;
1318
+ const parts = this.styles.legend.length
1319
+ ? this.styles.legend.map((l) => `${l.channel} = ${l.field}`)
1320
+ : ['density = rps (log)', 'speed = 1 / latency', 'ring = error'];
1321
+ const text = parts.join(' · ');
1322
+ ctx.save();
1323
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
1324
+ const w = Math.min(ctx.measureText(text).width + 20, this.width - 20);
1325
+ const h = 22;
1326
+ const x = Math.max(10, (this.width - w) / 2);
1327
+ const y = this.height - h - 9;
1328
+ ctx.globalAlpha = 0.95;
1329
+ ctx.fillStyle = theme.surface;
1330
+ ctx.strokeStyle = theme.border;
1331
+ ctx.lineWidth = 1;
1332
+ roundRect(ctx, x, y, w, h, 11);
1333
+ ctx.fill();
1334
+ ctx.stroke();
1335
+ ctx.globalAlpha = 1;
1336
+ ctx.fillStyle = theme.textMuted;
1337
+ ctx.textAlign = 'center';
1338
+ ctx.textBaseline = 'middle';
1339
+ ctx.fillText(ellipsis(ctx, text, w - 16), x + w / 2, y + h / 2);
1340
+ ctx.restore();
1341
+ }
1342
+ placeLabels() {
1343
+ const { ctx } = this;
1344
+ ctx.save();
1345
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
1346
+ const placed = [];
1347
+ for (const p of this.positions.values()) {
1348
+ placed.push({ x: p.x - p.width / 2 - 3, y: p.y - p.height / 2 - 3, w: p.width + 6, h: p.height + 6 });
1349
+ }
1350
+ const candidates = [0.5, 0.4, 0.6, 0.32, 0.68, 0.24, 0.76, 0.18, 0.82];
1351
+ const order = [...this.views].sort((a, b) => b.edge.metrics.rps - a.edge.metrics.rps);
1352
+ for (const v of order) {
1353
+ const text = edgeLabelText(v.edge);
1354
+ const w = ctx.measureText(text).width + 10;
1355
+ const h = 17;
1356
+ let chosen = null;
1357
+ for (const t of candidates) {
1358
+ const p = v.path.pointAt(t);
1359
+ const rect = { x: p.x - w / 2, y: p.y - h / 2, w, h };
1360
+ if (!placed.some((o) => overlaps(o, rect))) {
1361
+ chosen = t;
1362
+ placed.push(rect);
1363
+ break;
1364
+ }
1365
+ }
1366
+ v.labelT = chosen;
1367
+ }
1368
+ ctx.restore();
1369
+ }
1370
+ // --- interaction ---------------------------------------------------------
1371
+ hitTest(x, y) {
1372
+ for (const node of this.scene.nodes) {
1373
+ const p = this.positions.get(node.id);
1374
+ if (!p)
1375
+ continue;
1376
+ if (x >= p.x - p.width / 2 &&
1377
+ x <= p.x + p.width / 2 &&
1378
+ y >= p.y - p.height / 2 &&
1379
+ y <= p.y + p.height / 2) {
1380
+ return { kind: 'node', node };
1381
+ }
1382
+ }
1383
+ let best = null;
1384
+ for (const v of this.views) {
1385
+ const d = distanceToPath(v.path, { x, y });
1386
+ if (d < 9 && (!best || d < best.d))
1387
+ best = { v, d };
1388
+ }
1389
+ return best ? { kind: 'edge', edge: best.v.edge } : null;
1390
+ }
1391
+ updateTooltip() {
1392
+ const t = this.hovered;
1393
+ if (!t || !this.pointer) {
1394
+ this.tooltip.style.opacity = '0';
1395
+ return;
1396
+ }
1397
+ this.tooltip.style.background = this.theme.tooltipBg;
1398
+ this.tooltip.style.color = this.theme.tooltipText;
1399
+ this.tooltip.textContent =
1400
+ t.kind === 'node'
1401
+ ? nodeTooltip(t.node, this.failure.health.get(t.node.id) ?? 'ok')
1402
+ : edgeTooltip(t.edge, this.styles.edges.get(t.edge.id));
1403
+ this.tooltip.style.opacity = '1';
1404
+ const left = Math.min(this.pointer.x + 14, this.width - this.tooltip.offsetWidth - 8);
1405
+ const top = Math.min(this.pointer.y + 14, this.height - this.tooltip.offsetHeight - 8);
1406
+ this.tooltip.style.left = `${Math.max(8, left)}px`;
1407
+ this.tooltip.style.top = `${Math.max(8, top)}px`;
1408
+ }
1409
+ }
1410
+ // --- helpers ---------------------------------------------------------------
1411
+ function drawGlyph(ctx, glyph, x, y, r, color, tan) {
1412
+ ctx.fillStyle = color;
1413
+ ctx.strokeStyle = color;
1414
+ switch (glyph) {
1415
+ case 'ring':
1416
+ ctx.lineWidth = 1.3;
1417
+ ctx.beginPath();
1418
+ ctx.arc(x, y, r + 0.6, 0, Math.PI * 2);
1419
+ ctx.stroke();
1420
+ return;
1421
+ case 'square':
1422
+ ctx.fillRect(x - r, y - r, r * 2, r * 2);
1423
+ return;
1424
+ case 'triangle':
1425
+ ctx.beginPath();
1426
+ ctx.moveTo(x + tan.x * r * 1.5, y + tan.y * r * 1.5);
1427
+ ctx.lineTo(x - tan.x * r - tan.y * r, y - tan.y * r + tan.x * r);
1428
+ ctx.lineTo(x - tan.x * r + tan.y * r, y - tan.y * r - tan.x * r);
1429
+ ctx.closePath();
1430
+ ctx.fill();
1431
+ return;
1432
+ case 'bar':
1433
+ ctx.lineWidth = r * 1.6;
1434
+ ctx.beginPath();
1435
+ ctx.moveTo(x - tan.x * r * 1.4, y - tan.y * r * 1.4);
1436
+ ctx.lineTo(x + tan.x * r * 1.4, y + tan.y * r * 1.4);
1437
+ ctx.stroke();
1438
+ return;
1439
+ case 'diamond':
1440
+ ctx.beginPath();
1441
+ ctx.moveTo(x, y - r * 1.35);
1442
+ ctx.lineTo(x + r * 1.35, y);
1443
+ ctx.lineTo(x, y + r * 1.35);
1444
+ ctx.lineTo(x - r * 1.35, y);
1445
+ ctx.closePath();
1446
+ ctx.fill();
1447
+ return;
1448
+ default:
1449
+ ctx.beginPath();
1450
+ ctx.arc(x, y, r, 0, Math.PI * 2);
1451
+ ctx.fill();
1452
+ }
1453
+ }
1454
+ /**
1455
+ * Which previous nodes each new node came from. Two nodes are related when they
1456
+ * agree on every dimension their keys share - so `orders` and
1457
+ * `orders / POST /orders` are the same thing at two resolutions, and a merge or
1458
+ * a split is just that relation read in one direction or the other.
1459
+ */
1460
+ function ancestorMap(previous, next) {
1461
+ const out = new Map();
1462
+ if (!previous.nodes.length)
1463
+ return out;
1464
+ const shared = previous.nodeKey.filter((k) => next.nodeKey.includes(k));
1465
+ for (const node of next.nodes) {
1466
+ if (previous.nodes.some((p) => p.id === node.id)) {
1467
+ out.set(node.id, [node.id]);
1468
+ continue;
1469
+ }
1470
+ if (!shared.length)
1471
+ continue;
1472
+ const matches = previous.nodes
1473
+ .filter((p) => shared.every((k) => p.key[k] === node.key[k]))
1474
+ .map((p) => p.id);
1475
+ if (matches.length)
1476
+ out.set(node.id, matches);
1477
+ }
1478
+ return out;
1479
+ }
1480
+ /**
1481
+ * Carry particle streams across a re-projection so the flows do not blink out.
1482
+ * An edge inherits the stream of the old edge between its ancestors.
1483
+ */
1484
+ function adoptStreams(previous, next, views) {
1485
+ const out = new Map();
1486
+ if (!views.length)
1487
+ return out;
1488
+ const byId = new Map(views.map((v) => [v.edge.id, v.stream]));
1489
+ const shared = previous.nodeKey.filter((k) => next.nodeKey.includes(k));
1490
+ const nodeMap = new Map();
1491
+ for (const p of previous.nodes) {
1492
+ const target = next.nodes.find((n) => n.id === p.id || (shared.length > 0 && shared.every((k) => n.key[k] === p.key[k])));
1493
+ if (target)
1494
+ nodeMap.set(p.id, target.id);
1495
+ }
1496
+ const taken = new Set();
1497
+ for (const edge of next.edges) {
1498
+ if (byId.has(edge.id) && !taken.has(edge.id)) {
1499
+ out.set(edge.id, byId.get(edge.id));
1500
+ taken.add(edge.id);
1501
+ continue;
1502
+ }
1503
+ const donor = previous.edges.find((p) => !taken.has(p.id) &&
1504
+ nodeMap.get(p.from) === edge.from &&
1505
+ nodeMap.get(p.to) === edge.to &&
1506
+ byId.has(p.id));
1507
+ if (donor) {
1508
+ out.set(edge.id, byId.get(donor.id));
1509
+ taken.add(donor.id);
1510
+ }
1511
+ }
1512
+ return out;
1513
+ }
1514
+ function interpolate(origins, targets, t) {
1515
+ const out = new Map();
1516
+ for (const [id, target] of targets) {
1517
+ const from = origins.get(id);
1518
+ if (!from) {
1519
+ out.set(id, target);
1520
+ continue;
1521
+ }
1522
+ out.set(id, {
1523
+ ...target,
1524
+ x: from.x + (target.x - from.x) * t,
1525
+ y: from.y + (target.y - from.y) * t,
1526
+ });
1527
+ }
1528
+ return out;
1529
+ }
1530
+ /** Minimal shim so EdgeStream keeps its v0.1 constructor shape. */
1531
+ function asMetricEdge(edge) {
1532
+ return { id: edge.id, metrics: edge.metrics };
1533
+ }
1534
+ function edgeLabelText(edge) {
1535
+ return `${formatRps(edge.metrics.rps)}/s · ${formatLatency(edge.metrics.latencyMs)}`;
1536
+ }
1537
+ function overlaps(a, b) {
1538
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
1539
+ }
1540
+ /** A dimension value a node carries, whether it is part of its key or just constant across it. */
1541
+ function nodeDimValue(node, field) {
1542
+ return node.key[field] ?? (node.spans[field]?.length === 1 ? node.spans[field][0] : undefined);
1543
+ }
1544
+ function targetId(t) {
1545
+ if (!t)
1546
+ return null;
1547
+ return t.kind === 'node' ? `n:${t.node.id}` : `e:${t.edge.id}`;
1548
+ }
1549
+ function nodeTooltip(node, health) {
1550
+ const lines = [node.sublabel ? `${node.sublabel} · ${node.label}` : node.label];
1551
+ for (const [k, v] of Object.entries(node.key))
1552
+ lines.push(`${pad(k)}${v}`);
1553
+ lines.push(`${pad('inbound')}${formatRps(node.inboundRps)}/s`);
1554
+ lines.push(`${pad('outbound')}${formatRps(node.outboundRps)}/s`);
1555
+ if (health !== 'ok')
1556
+ lines.push(`${pad('state')}${health.toUpperCase()}`);
1557
+ const spans = Object.entries(node.spans).filter(([, v]) => v.length && v.length <= 6);
1558
+ for (const [k, v] of spans.slice(0, 4))
1559
+ lines.push(`${pad(k)}${v.join(', ')}`);
1560
+ if (node.records > 1)
1561
+ lines.push(`${pad('records')}${node.records}`);
1562
+ return lines.join('\n');
1563
+ }
1564
+ function edgeTooltip(edge, style) {
1565
+ const m = edge.metrics;
1566
+ const lines = [`${edge.from} → ${edge.to}`];
1567
+ if (edge.label)
1568
+ lines.push(edge.label);
1569
+ for (const [k, v] of Object.entries(edge.dims))
1570
+ lines.push(`${pad(k)}${v}`);
1571
+ lines.push(`${pad('rps')}${formatRps(m.rps)}/s`);
1572
+ lines.push(`${pad('latency')}${formatLatency(m.latencyMs)}`);
1573
+ lines.push(`${pad('errors')}${(m.errorRate * 100).toFixed(m.errorRate < 0.01 ? 2 : 1)}%`);
1574
+ lines.push(`${pad('payload')}${formatBytes(m.bytes)}`);
1575
+ if (style && style.coupling !== 'plain')
1576
+ lines.push(`${pad('coupling')}${style.coupling}`);
1577
+ const spans = Object.entries(edge.spans).filter(([, v]) => v.length > 1 && v.length <= 6);
1578
+ for (const [k, v] of spans.slice(0, 3))
1579
+ lines.push(`${pad(k)}${v.join(', ')}`);
1580
+ if (edge.records > 1)
1581
+ lines.push(`${pad('records')}${edge.records}`);
1582
+ return lines.join('\n');
1583
+ }
1584
+ function pad(label) {
1585
+ return `${label.padEnd(10)}`;
1586
+ }
1587
+ function roundRect(ctx, x, y, w, h, r) {
1588
+ const rad = Math.min(r, w / 2, h / 2);
1589
+ ctx.beginPath();
1590
+ ctx.moveTo(x + rad, y);
1591
+ ctx.arcTo(x + w, y, x + w, y + h, rad);
1592
+ ctx.arcTo(x + w, y + h, x, y + h, rad);
1593
+ ctx.arcTo(x, y + h, x, y, rad);
1594
+ ctx.arcTo(x, y, x + w, y, rad);
1595
+ ctx.closePath();
1596
+ }
1597
+ const LABEL_FONT = (size) => `600 ${size}px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif`;
1598
+ /**
1599
+ * Get a name into the width available: break at a separator, then shrink, then
1600
+ * as a last resort trim. Leaves `ctx.font` set to whatever size it settled on.
1601
+ */
1602
+ function fitLabel(ctx, text, maxWidth) {
1603
+ if (ctx.measureText(text).width <= maxWidth)
1604
+ return { text: [text] };
1605
+ // A separator break, if one leaves both halves fitting.
1606
+ const separators = [...text.matchAll(/[-_/. ]/g)].map((m) => m.index ?? 0);
1607
+ const middle = text.length / 2;
1608
+ for (const at of separators.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle))) {
1609
+ const head = text.slice(0, at + 1);
1610
+ const tail = text.slice(at + 1);
1611
+ if (ctx.measureText(head).width <= maxWidth && ctx.measureText(tail).width <= maxWidth) {
1612
+ return { text: [head, tail] };
1613
+ }
1614
+ }
1615
+ for (const size of [11, 10, 9, 8]) {
1616
+ ctx.font = LABEL_FONT(size);
1617
+ if (ctx.measureText(text).width <= maxWidth)
1618
+ return { text: [text] };
1619
+ }
1620
+ return { text: [ellipsis(ctx, text, maxWidth)] };
1621
+ }
1622
+ function ellipsis(ctx, text, maxWidth) {
1623
+ if (ctx.measureText(text).width <= maxWidth)
1624
+ return text;
1625
+ let s = text;
1626
+ while (s.length > 1 && ctx.measureText(`${s}…`).width > maxWidth)
1627
+ s = s.slice(0, -1);
1628
+ return `${s}…`;
1629
+ }
1630
+ /** How many lanes still read as one mixed stream rather than needing their own track. */
1631
+ const OVERLAY_LANES = 3;
1632
+ /**
1633
+ * Decide how the lanes on one edge share the space between two boxes.
1634
+ *
1635
+ * Reciprocal edges always separate - a response must not hide under the request
1636
+ * it answers. What varies is how a *split* is drawn, and the right answer
1637
+ * depends on how many values the split produced:
1638
+ *
1639
+ * - **Two or three** (a region pair, three environments): draw them nearly on
1640
+ * top of each other, scattered wide across the conduit and emitting out of
1641
+ * phase, so the colours mix in one pipe. That overlap is the information -
1642
+ * you see at a glance that this dependency carries both regions, and roughly
1643
+ * in what ratio, which two tidy parallel lanes actively hide.
1644
+ * - **Four or more**: separate tracks. Past a handful of colours, a mixed
1645
+ * stream is just noise, and you need to be able to follow one.
1646
+ */
1647
+ function laneOffsets(edges, styles) {
1648
+ const groups = new Map();
1649
+ for (const e of edges) {
1650
+ const key = e.from < e.to ? `${e.from}|${e.to}` : `${e.to}|${e.from}`;
1651
+ const dir = e.from < e.to ? 0 : 1;
1652
+ let g = groups.get(key);
1653
+ if (!g)
1654
+ groups.set(key, (g = [[], []]));
1655
+ g[dir].push(e);
1656
+ }
1657
+ const out = new Map();
1658
+ for (const [, dirs] of groups) {
1659
+ const bidirectional = dirs[0].length > 0 && dirs[1].length > 0;
1660
+ for (const list of dirs) {
1661
+ const sorted = [...list].sort((a, b) => (styles.get(a.id)?.lane ?? 0) - (styles.get(b.id)?.lane ?? 0));
1662
+ const n = sorted.length;
1663
+ const overlay = n > 1 && n <= OVERLAY_LANES;
1664
+ const step = overlay ? 3.2 : 19;
1665
+ const spread = overlay ? 11 : 4.2;
1666
+ sorted.forEach((e, i) => {
1667
+ const within = (i - (n - 1) / 2) * step;
1668
+ out.set(e.id, {
1669
+ offset: bidirectional ? 12 + Math.abs(within) + (overlay ? 0 : i * 4) : within,
1670
+ spread,
1671
+ // Stagger the emission so the lanes interleave instead of pulsing together.
1672
+ phase: n > 1 ? i / n : 0,
1673
+ });
1674
+ });
1675
+ }
1676
+ }
1677
+ return out;
1678
+ }
1679
+ /** "#3b82f6" or "rgb(...)" -> rgba with the given alpha. */
1680
+ function withAlpha(color, alpha) {
1681
+ if (color.startsWith('#')) {
1682
+ const h = color.slice(1);
1683
+ const n = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
1684
+ const v = Number.parseInt(n, 16);
1685
+ return `rgba(${(v >> 16) & 255}, ${(v >> 8) & 255}, ${v & 255}, ${alpha})`;
1686
+ }
1687
+ const m = color.match(/rgba?\(([^)]+)\)/);
1688
+ if (m) {
1689
+ const [r, g, b] = m[1].split(',').map((x) => x.trim());
1690
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
1691
+ }
1692
+ return color;
1693
+ }
1694
+ function clamp(v, lo, hi) {
1695
+ return v < lo ? lo : v > hi ? hi : v;
1696
+ }
1697
+ function clamp01(v) {
1698
+ return v < 0 ? 0 : v > 1 ? 1 : v;
1699
+ }
1700
+ function easeOutCubic(t) {
1701
+ return 1 - (1 - t) ** 3;
1702
+ }
1703
+ function prefersReducedMotion() {
1704
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
1705
+ }
1706
+ function now() {
1707
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
1708
+ }
1709
+ export { couplingFor };
1710
+ //# sourceMappingURL=renderer.js.map