@opendata-ai/openchart-vanilla 2.0.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 (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Canvas 2D renderer for force-directed graph visualization.
3
+ *
4
+ * Stateless renderer: receives a GraphRenderState each frame and draws it.
5
+ * Handles DPR scaling, viewport culling, LOD labels, dark mode glow effects,
6
+ * and batched drawing for performance at 10k+ nodes.
7
+ *
8
+ * Performance strategy:
9
+ * - Edges batched by (stroke, strokeWidth, dash) key → one stroke() per group
10
+ * - Nodes batched by fill color → one fill() per color group
11
+ * - Node strokes batched by stroke color
12
+ * - Labels and glow skipped during active pan/zoom gestures
13
+ */
14
+
15
+ import type { GraphRenderState, PositionedEdge, PositionedNode } from './types';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const LABEL_FONT_MIN = 10;
22
+ const LABEL_FONT_MAX = 14;
23
+ const EDGE_ALPHA_DEFAULT = 0.35;
24
+ const EDGE_ALPHA_CONNECTED = 1.0;
25
+ const EDGE_ALPHA_DIMMED = 0.05;
26
+ const SEARCH_NON_MATCH_ALPHA = 0.15;
27
+ const GLOW_NODE_THRESHOLD = 2000;
28
+ const GLOW_RADIUS_MULTIPLIER = 1.5;
29
+ const GLOW_ALPHA = 0.2;
30
+ const CULL_MARGIN = 50;
31
+ const TWO_PI = Math.PI * 2;
32
+
33
+ /** Minimum node radius in screen pixels. Keeps nodes visible when zoomed out. */
34
+ const MIN_SCREEN_RADIUS = 2.5;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers (exported for testing)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Compute label visibility threshold from zoom level.
42
+ * At zoom 0.2 (zoomed out): threshold ~1.0 (only top ~5% visible).
43
+ * At zoom 2.0+: threshold ~0.0 (all visible).
44
+ */
45
+ export function labelThreshold(zoom: number): number {
46
+ const t = Math.max(0, Math.min(1, (zoom - 0.2) / 1.8));
47
+ return 1 - t;
48
+ }
49
+
50
+ /** Compute visible rect in graph coordinates from canvas size + transform. */
51
+ export function visibleRect(
52
+ canvasWidth: number,
53
+ canvasHeight: number,
54
+ transform: { x: number; y: number; k: number },
55
+ margin: number = CULL_MARGIN,
56
+ ): { minX: number; minY: number; maxX: number; maxY: number } {
57
+ const { x, y, k } = transform;
58
+ return {
59
+ minX: (-x - margin) / k,
60
+ minY: (-y - margin) / k,
61
+ maxX: (canvasWidth - x + margin) / k,
62
+ maxY: (canvasHeight - y + margin) / k,
63
+ };
64
+ }
65
+
66
+ /** Check if a node falls within the visible rect. */
67
+ function nodeInView(
68
+ node: PositionedNode,
69
+ rect: { minX: number; minY: number; maxX: number; maxY: number },
70
+ ): boolean {
71
+ return (
72
+ node.x + node.radius >= rect.minX &&
73
+ node.x - node.radius <= rect.maxX &&
74
+ node.y + node.radius >= rect.minY &&
75
+ node.y - node.radius <= rect.maxY
76
+ );
77
+ }
78
+
79
+ /** Check if an edge has at least one endpoint in view. */
80
+ function edgeInView(
81
+ edge: PositionedEdge,
82
+ rect: { minX: number; minY: number; maxX: number; maxY: number },
83
+ ): boolean {
84
+ return (
85
+ (edge.sourceX >= rect.minX &&
86
+ edge.sourceX <= rect.maxX &&
87
+ edge.sourceY >= rect.minY &&
88
+ edge.sourceY <= rect.maxY) ||
89
+ (edge.targetX >= rect.minX &&
90
+ edge.targetX <= rect.maxX &&
91
+ edge.targetY >= rect.minY &&
92
+ edge.targetY <= rect.maxY)
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Dash patterns for edge styles
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const DASH_PATTERNS: Record<string, number[]> = {
101
+ solid: [],
102
+ dashed: [6, 4],
103
+ dotted: [2, 3],
104
+ };
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // GraphCanvasRenderer
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export class GraphCanvasRenderer {
111
+ private canvas: HTMLCanvasElement;
112
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
113
+ private ctx: CanvasRenderingContext2D;
114
+ private dpr: number;
115
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
116
+ private cssWidth = 0;
117
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
118
+ private cssHeight = 0;
119
+
120
+ constructor(canvas: HTMLCanvasElement) {
121
+ this.canvas = canvas;
122
+ this.ctx = canvas.getContext('2d')!;
123
+ this.dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
124
+ }
125
+
126
+ /** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
127
+ resize(width: number, height: number): void {
128
+ this.cssWidth = width;
129
+ this.cssHeight = height;
130
+ this.canvas.width = width * this.dpr;
131
+ this.canvas.height = height * this.dpr;
132
+ this.canvas.style.width = `${width}px`;
133
+ this.canvas.style.height = `${height}px`;
134
+ }
135
+
136
+ /** Clear canvas and render the full graph state. */
137
+ render(state: GraphRenderState): void {
138
+ const { ctx, dpr, cssWidth, cssHeight } = this;
139
+ const {
140
+ nodes,
141
+ edges,
142
+ transform,
143
+ hoveredNodeId,
144
+ selectedNodeIds,
145
+ adjacencyMap,
146
+ theme,
147
+ searchMatches,
148
+ isGesturing,
149
+ } = state;
150
+
151
+ const hasActiveNode = hoveredNodeId !== null || selectedNodeIds.size > 0;
152
+ const activeNodeIds = new Set<string>();
153
+ if (hoveredNodeId) activeNodeIds.add(hoveredNodeId);
154
+ for (const id of selectedNodeIds) activeNodeIds.add(id);
155
+
156
+ // Collect all nodes connected to any active node
157
+ const connectedNodeIds = new Set<string>();
158
+ for (const id of activeNodeIds) {
159
+ connectedNodeIds.add(id);
160
+ const neighbors = adjacencyMap.get(id);
161
+ if (neighbors) {
162
+ for (const nid of neighbors) connectedNodeIds.add(nid);
163
+ }
164
+ }
165
+
166
+ // Viewport culling
167
+ const rect = visibleRect(cssWidth, cssHeight, transform);
168
+ const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
169
+ const visibleEdges = edges.filter((e) => edgeInView(e, rect));
170
+
171
+ const isDark = theme.isDark;
172
+ const showGlow = isDark && !isGesturing && visibleNodes.length < GLOW_NODE_THRESHOLD;
173
+ const threshold = labelThreshold(transform.k);
174
+ // Minimum radius in graph coordinates so nodes stay visible when zoomed out
175
+ const minRadius = MIN_SCREEN_RADIUS / transform.k;
176
+
177
+ // -- Clear and apply transform --
178
+ ctx.save();
179
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
180
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
181
+
182
+ // Fill background
183
+ ctx.fillStyle = theme.colors.background;
184
+ ctx.fillRect(0, 0, cssWidth, cssHeight);
185
+
186
+ ctx.translate(transform.x, transform.y);
187
+ ctx.scale(transform.k, transform.k);
188
+
189
+ // -- Draw edges (batched by style key) --
190
+ this.drawEdgesBatched(
191
+ ctx,
192
+ visibleEdges,
193
+ hasActiveNode,
194
+ connectedNodeIds,
195
+ isGesturing ? null : searchMatches,
196
+ );
197
+
198
+ // -- Draw nodes (batched by fill color) --
199
+ this.drawNodesBatched(
200
+ ctx,
201
+ visibleNodes,
202
+ hoveredNodeId,
203
+ selectedNodeIds,
204
+ isGesturing ? null : searchMatches,
205
+ showGlow,
206
+ theme,
207
+ minRadius,
208
+ );
209
+
210
+ // -- Draw labels (skipped during gestures) --
211
+ if (!isGesturing) {
212
+ this.drawLabels(
213
+ ctx,
214
+ visibleNodes,
215
+ threshold,
216
+ hoveredNodeId,
217
+ selectedNodeIds,
218
+ searchMatches,
219
+ transform.k,
220
+ theme,
221
+ );
222
+ }
223
+
224
+ ctx.restore();
225
+ }
226
+
227
+ // -------------------------------------------------------------------------
228
+ // Batched edge drawing
229
+ // -------------------------------------------------------------------------
230
+
231
+ private drawEdgesBatched(
232
+ ctx: CanvasRenderingContext2D,
233
+ edges: PositionedEdge[],
234
+ hasActiveNode: boolean,
235
+ connectedNodeIds: Set<string>,
236
+ searchMatches: Set<string> | null,
237
+ ): void {
238
+ // Classify edges by alpha level, then batch by visual style within each level
239
+ const dimmedEdges: PositionedEdge[] = [];
240
+ const defaultEdges: PositionedEdge[] = [];
241
+ const connectedEdges: PositionedEdge[] = [];
242
+
243
+ for (const edge of edges) {
244
+ const isConnected =
245
+ hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
246
+ const isDimmed = hasActiveNode && !isConnected;
247
+
248
+ if (isConnected) {
249
+ connectedEdges.push(edge);
250
+ } else if (isDimmed) {
251
+ dimmedEdges.push(edge);
252
+ } else {
253
+ defaultEdges.push(edge);
254
+ }
255
+ }
256
+
257
+ // Draw dimmed first, then default, then connected (on top)
258
+ this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
259
+ this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
260
+ this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
261
+ }
262
+
263
+ /**
264
+ * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
265
+ * When search is inactive, all edges of the same style are drawn in a single path.
266
+ * When search is active, edges split by search-match status for alpha dimming.
267
+ */
268
+ private drawEdgeGroupBatched(
269
+ ctx: CanvasRenderingContext2D,
270
+ edges: PositionedEdge[],
271
+ alpha: number,
272
+ searchMatches: Set<string> | null,
273
+ ): void {
274
+ if (edges.length === 0) return;
275
+
276
+ // Group by visual key: stroke + strokeWidth + style
277
+ const groups = new Map<string, PositionedEdge[]>();
278
+ for (const edge of edges) {
279
+ const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
280
+ let group = groups.get(key);
281
+ if (!group) {
282
+ group = [];
283
+ groups.set(key, group);
284
+ }
285
+ group.push(edge);
286
+ }
287
+
288
+ for (const [, group] of groups) {
289
+ const sample = group[0];
290
+ const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
291
+ ctx.setLineDash(dash);
292
+ ctx.strokeStyle = sample.stroke;
293
+ ctx.lineWidth = sample.strokeWidth;
294
+
295
+ if (!searchMatches) {
296
+ // Fast path: single batched path for all edges in this group
297
+ ctx.globalAlpha = alpha;
298
+ ctx.beginPath();
299
+ for (const edge of group) {
300
+ ctx.moveTo(edge.sourceX, edge.sourceY);
301
+ ctx.lineTo(edge.targetX, edge.targetY);
302
+ }
303
+ ctx.stroke();
304
+ } else {
305
+ // Search active: split into matched and non-matched batches
306
+ ctx.globalAlpha = alpha;
307
+ ctx.beginPath();
308
+ let hasMatched = false;
309
+
310
+ const nonMatchPath: PositionedEdge[] = [];
311
+
312
+ for (const edge of group) {
313
+ const srcMatch = searchMatches.has(edge.source);
314
+ const tgtMatch = searchMatches.has(edge.target);
315
+ if (srcMatch || tgtMatch) {
316
+ ctx.moveTo(edge.sourceX, edge.sourceY);
317
+ ctx.lineTo(edge.targetX, edge.targetY);
318
+ hasMatched = true;
319
+ } else {
320
+ nonMatchPath.push(edge);
321
+ }
322
+ }
323
+ if (hasMatched) ctx.stroke();
324
+
325
+ // Draw non-matching edges dimmed
326
+ if (nonMatchPath.length > 0) {
327
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * alpha;
328
+ ctx.beginPath();
329
+ for (const edge of nonMatchPath) {
330
+ ctx.moveTo(edge.sourceX, edge.sourceY);
331
+ ctx.lineTo(edge.targetX, edge.targetY);
332
+ }
333
+ ctx.stroke();
334
+ }
335
+ }
336
+ }
337
+
338
+ ctx.setLineDash([]);
339
+ ctx.globalAlpha = 1;
340
+ }
341
+
342
+ // -------------------------------------------------------------------------
343
+ // Batched node drawing
344
+ // -------------------------------------------------------------------------
345
+
346
+ private drawNodesBatched(
347
+ ctx: CanvasRenderingContext2D,
348
+ nodes: PositionedNode[],
349
+ hoveredNodeId: string | null,
350
+ selectedNodeIds: Set<string>,
351
+ searchMatches: Set<string> | null,
352
+ showGlow: boolean,
353
+ theme: GraphRenderState['theme'],
354
+ minRadius: number,
355
+ ): void {
356
+ // Separate special nodes (hovered/selected) from bulk nodes.
357
+ // Special nodes need individual treatment; bulk nodes get batched by color.
358
+ const bulkNodes: PositionedNode[] = [];
359
+ const specialNodes: PositionedNode[] = [];
360
+
361
+ for (const node of nodes) {
362
+ if (node.id === hoveredNodeId || selectedNodeIds.has(node.id)) {
363
+ specialNodes.push(node);
364
+ } else {
365
+ bulkNodes.push(node);
366
+ }
367
+ }
368
+
369
+ // Helper: effective radius clamped to minimum screen size
370
+ const r = (node: PositionedNode) => Math.max(node.radius, minRadius);
371
+
372
+ // --- Glow pass (dark mode only, before fills) ---
373
+ if (showGlow) {
374
+ this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
375
+ }
376
+
377
+ // --- Bulk fill pass: batch by fill color ---
378
+ const fillGroups = new Map<string, PositionedNode[]>();
379
+ for (const node of bulkNodes) {
380
+ let group = fillGroups.get(node.fill);
381
+ if (!group) {
382
+ group = [];
383
+ fillGroups.set(node.fill, group);
384
+ }
385
+ group.push(node);
386
+ }
387
+
388
+ if (!searchMatches) {
389
+ // Fast path: no search dimming
390
+ ctx.globalAlpha = 1;
391
+ for (const [fill, group] of fillGroups) {
392
+ ctx.fillStyle = fill;
393
+ ctx.beginPath();
394
+ for (const node of group) {
395
+ const nr = r(node);
396
+ ctx.moveTo(node.x + nr, node.y);
397
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
398
+ }
399
+ ctx.fill();
400
+ }
401
+ } else {
402
+ // Search active: split each color group into matched/dimmed batches
403
+ for (const [fill, group] of fillGroups) {
404
+ ctx.fillStyle = fill;
405
+
406
+ // Matched nodes
407
+ ctx.globalAlpha = 1;
408
+ ctx.beginPath();
409
+ let hasMatched = false;
410
+ const dimmedNodes: PositionedNode[] = [];
411
+
412
+ for (const node of group) {
413
+ if (searchMatches.has(node.id)) {
414
+ const nr = r(node);
415
+ ctx.moveTo(node.x + nr, node.y);
416
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
417
+ hasMatched = true;
418
+ } else {
419
+ dimmedNodes.push(node);
420
+ }
421
+ }
422
+ if (hasMatched) ctx.fill();
423
+
424
+ // Dimmed nodes
425
+ if (dimmedNodes.length > 0) {
426
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
427
+ ctx.beginPath();
428
+ for (const node of dimmedNodes) {
429
+ const nr = r(node);
430
+ ctx.moveTo(node.x + nr, node.y);
431
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
432
+ }
433
+ ctx.fill();
434
+ }
435
+ }
436
+ }
437
+
438
+ // --- Bulk stroke pass: batch by stroke color ---
439
+ const strokeGroups = new Map<string, PositionedNode[]>();
440
+ for (const node of bulkNodes) {
441
+ const key = `${node.stroke}|${node.strokeWidth}`;
442
+ let group = strokeGroups.get(key);
443
+ if (!group) {
444
+ group = [];
445
+ strokeGroups.set(key, group);
446
+ }
447
+ group.push(node);
448
+ }
449
+
450
+ for (const [key, group] of strokeGroups) {
451
+ const [stroke, widthStr] = key.split('|');
452
+ ctx.strokeStyle = stroke;
453
+ ctx.lineWidth = parseFloat(widthStr);
454
+
455
+ if (!searchMatches) {
456
+ ctx.globalAlpha = 1;
457
+ ctx.beginPath();
458
+ for (const node of group) {
459
+ const nr = r(node);
460
+ ctx.moveTo(node.x + nr, node.y);
461
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
462
+ }
463
+ ctx.stroke();
464
+ } else {
465
+ // Split matched/dimmed for strokes too
466
+ ctx.globalAlpha = 1;
467
+ ctx.beginPath();
468
+ let hasMatched = false;
469
+ const dimmedNodes: PositionedNode[] = [];
470
+
471
+ for (const node of group) {
472
+ if (searchMatches.has(node.id)) {
473
+ const nr = r(node);
474
+ ctx.moveTo(node.x + nr, node.y);
475
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
476
+ hasMatched = true;
477
+ } else {
478
+ dimmedNodes.push(node);
479
+ }
480
+ }
481
+ if (hasMatched) ctx.stroke();
482
+
483
+ if (dimmedNodes.length > 0) {
484
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
485
+ ctx.beginPath();
486
+ for (const node of dimmedNodes) {
487
+ const nr = r(node);
488
+ ctx.moveTo(node.x + nr, node.y);
489
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
490
+ }
491
+ ctx.stroke();
492
+ }
493
+ }
494
+ }
495
+
496
+ // --- Special nodes (hovered/selected) drawn individually ---
497
+ for (const node of specialNodes) {
498
+ const isHovered = node.id === hoveredNodeId;
499
+ const isSelected = selectedNodeIds.has(node.id);
500
+ const dimmed = searchMatches !== null && !searchMatches.has(node.id);
501
+ const baseRadius = Math.max(node.radius, minRadius);
502
+ const radius = isHovered ? baseRadius * 1.15 : baseRadius;
503
+
504
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
505
+
506
+ // Glow for special nodes
507
+ if (showGlow && !dimmed) {
508
+ ctx.beginPath();
509
+ ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
510
+ ctx.fillStyle = node.fill;
511
+ ctx.globalAlpha = GLOW_ALPHA;
512
+ ctx.fill();
513
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
514
+ }
515
+
516
+ // Fill
517
+ ctx.beginPath();
518
+ ctx.arc(node.x, node.y, radius, 0, TWO_PI);
519
+ ctx.fillStyle = isHovered ? brighten(node.fill) : node.fill;
520
+ ctx.fill();
521
+
522
+ // Stroke
523
+ ctx.strokeStyle = node.stroke;
524
+ ctx.lineWidth = node.strokeWidth;
525
+ ctx.stroke();
526
+
527
+ // Selection ring
528
+ if (isSelected) {
529
+ ctx.beginPath();
530
+ ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
531
+ ctx.strokeStyle = theme.colors.categorical[0] ?? '#3b82f6';
532
+ ctx.lineWidth = 2;
533
+ ctx.stroke();
534
+ }
535
+ }
536
+
537
+ ctx.globalAlpha = 1;
538
+ }
539
+
540
+ /** Batch glow circles by fill color. */
541
+ private drawGlowBatched(
542
+ ctx: CanvasRenderingContext2D,
543
+ nodes: PositionedNode[],
544
+ searchMatches: Set<string> | null,
545
+ minRadius: number,
546
+ ): void {
547
+ const glowGroups = new Map<string, PositionedNode[]>();
548
+ for (const node of nodes) {
549
+ if (searchMatches && !searchMatches.has(node.id)) continue;
550
+ let group = glowGroups.get(node.fill);
551
+ if (!group) {
552
+ group = [];
553
+ glowGroups.set(node.fill, group);
554
+ }
555
+ group.push(node);
556
+ }
557
+
558
+ ctx.globalAlpha = GLOW_ALPHA;
559
+ for (const [fill, group] of glowGroups) {
560
+ ctx.fillStyle = fill;
561
+ ctx.beginPath();
562
+ for (const node of group) {
563
+ const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
564
+ ctx.moveTo(node.x + gr, node.y);
565
+ ctx.arc(node.x, node.y, gr, 0, TWO_PI);
566
+ }
567
+ ctx.fill();
568
+ }
569
+ ctx.globalAlpha = 1;
570
+ }
571
+
572
+ // -------------------------------------------------------------------------
573
+ // Labels (drawn individually, skipped during gestures)
574
+ // -------------------------------------------------------------------------
575
+
576
+ private drawLabels(
577
+ ctx: CanvasRenderingContext2D,
578
+ nodes: PositionedNode[],
579
+ threshold: number,
580
+ hoveredNodeId: string | null,
581
+ selectedNodeIds: Set<string>,
582
+ searchMatches: Set<string> | null,
583
+ zoom: number,
584
+ theme: GraphRenderState['theme'],
585
+ ): void {
586
+ // Font size inversely scaled by zoom, clamped to readable range
587
+ const rawSize = 12 / zoom;
588
+ const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
589
+
590
+ ctx.font = `${fontSize}px ${theme.fonts.family}`;
591
+ ctx.textAlign = 'center';
592
+ ctx.textBaseline = 'top';
593
+
594
+ for (const node of nodes) {
595
+ if (!node.label) continue;
596
+
597
+ const isHovered = node.id === hoveredNodeId;
598
+ const isSelected = selectedNodeIds.has(node.id);
599
+ const forced = isHovered || isSelected;
600
+ const dimmed = searchMatches !== null && !searchMatches.has(node.id);
601
+
602
+ // LOD: skip labels below threshold unless forced
603
+ if (!forced && node.labelPriority < threshold) continue;
604
+
605
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
606
+
607
+ const labelY = node.y + node.radius + 3;
608
+
609
+ // Dark halo for readability
610
+ ctx.strokeStyle = theme.colors.background;
611
+ ctx.lineWidth = 3;
612
+ ctx.lineJoin = 'round';
613
+ ctx.strokeText(node.label, node.x, labelY);
614
+
615
+ // White/light text
616
+ ctx.fillStyle = theme.colors.text;
617
+ ctx.fillText(node.label, node.x, labelY);
618
+ }
619
+
620
+ ctx.globalAlpha = 1;
621
+ }
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Color helpers
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /**
629
+ * Brighten a hex/rgb color by ~20% for hover effect.
630
+ * Quick and dirty approach: parse hex, lighten each channel.
631
+ */
632
+ function brighten(color: string): string {
633
+ // Handle rgb(r,g,b) or rgb(r, g, b)
634
+ const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
635
+ if (rgbMatch) {
636
+ const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
637
+ const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
638
+ const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
639
+ return `rgb(${r},${g},${b})`;
640
+ }
641
+
642
+ // Handle hex colors (#rgb and #rrggbb)
643
+ const hex = color.replace('#', '');
644
+ const full =
645
+ hex.length === 3
646
+ ? hex
647
+ .split('')
648
+ .map((c) => c + c)
649
+ .join('')
650
+ : hex;
651
+
652
+ if (full.length === 6) {
653
+ const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
654
+ const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
655
+ const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
656
+ return `rgb(${r},${g},${b})`;
657
+ }
658
+
659
+ return color;
660
+ }