@opendata-ai/openchart-vanilla 8.0.0-rc.9 → 8.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 (150) hide show
  1. package/dist/{chunk-SPILUE5S.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/{chunk-IZQBT6QM.js → chunk-6BUCYCLD.js} +7283 -5881
  3. package/dist/chunk-6BUCYCLD.js.map +1 -0
  4. package/dist/chunk-DCODCLWI.js +234 -0
  5. package/dist/chunk-DCODCLWI.js.map +1 -0
  6. package/dist/{chunk-QW7ZB6NE.js → chunk-EKAUQAAX.js} +49 -45
  7. package/dist/chunk-EKAUQAAX.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +309 -27
  16. package/dist/index.js +2020 -836
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-CL-nA0f-.d.ts} +85 -2
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.d.ts +2 -0
  21. package/dist/static.js +8 -69
  22. package/dist/static.js.map +1 -1
  23. package/dist/story/index.d.ts +25 -9
  24. package/dist/story/index.js +9 -7
  25. package/dist/story/index.js.map +1 -1
  26. package/dist/styles.css +1 -1
  27. package/dist/styles.css.map +1 -1
  28. package/package.json +14 -6
  29. package/dist/chunk-IZQBT6QM.js.map +0 -1
  30. package/dist/chunk-QW7ZB6NE.js.map +0 -1
  31. package/src/__test-fixtures__/dom.ts +0 -70
  32. package/src/__test-fixtures__/specs.ts +0 -214
  33. package/src/__tests__/animation.test.ts +0 -358
  34. package/src/__tests__/annotations-render.test.ts +0 -201
  35. package/src/__tests__/compound-labels.test.ts +0 -122
  36. package/src/__tests__/continuous-legend.test.ts +0 -91
  37. package/src/__tests__/crosshair.test.ts +0 -130
  38. package/src/__tests__/edit-events.test.ts +0 -1477
  39. package/src/__tests__/editable-prop.test.ts +0 -174
  40. package/src/__tests__/events.test.ts +0 -387
  41. package/src/__tests__/export.test.ts +0 -241
  42. package/src/__tests__/font-race.test.ts +0 -199
  43. package/src/__tests__/gradient-ids.test.ts +0 -159
  44. package/src/__tests__/gradient-utils.test.ts +0 -125
  45. package/src/__tests__/invert.test.ts +0 -187
  46. package/src/__tests__/known-bugs-render.test.ts +0 -183
  47. package/src/__tests__/map.test.ts +0 -636
  48. package/src/__tests__/mount.test.ts +0 -357
  49. package/src/__tests__/pattern-fills.test.ts +0 -105
  50. package/src/__tests__/resize-timing.test.ts +0 -218
  51. package/src/__tests__/sankey.test.ts +0 -133
  52. package/src/__tests__/selection-events.test.ts +0 -1062
  53. package/src/__tests__/series-search.test.ts +0 -311
  54. package/src/__tests__/static.test.ts +0 -141
  55. package/src/__tests__/svg-renderer.test.ts +0 -895
  56. package/src/__tests__/table-keyboard.test.ts +0 -361
  57. package/src/__tests__/table-mount.test.ts +0 -639
  58. package/src/__tests__/tilemap.test.ts +0 -158
  59. package/src/__tests__/tooltip.test.ts +0 -337
  60. package/src/__tests__/transition.test.ts +0 -1792
  61. package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
  62. package/src/__tests__/you-draw-it.test.ts +0 -273
  63. package/src/animation-vars.ts +0 -42
  64. package/src/animation.ts +0 -76
  65. package/src/barlist-mount.ts +0 -383
  66. package/src/barlist-renderer.ts +0 -267
  67. package/src/dom-helpers.ts +0 -17
  68. package/src/export.ts +0 -482
  69. package/src/gradient-utils.ts +0 -138
  70. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  71. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  72. package/src/graph/__tests__/interaction.test.ts +0 -205
  73. package/src/graph/__tests__/keyboard.test.ts +0 -653
  74. package/src/graph/__tests__/search.test.ts +0 -88
  75. package/src/graph/__tests__/simulation.test.ts +0 -233
  76. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  77. package/src/graph/__tests__/zoom.test.ts +0 -203
  78. package/src/graph/canvas-renderer.ts +0 -747
  79. package/src/graph/interaction.ts +0 -367
  80. package/src/graph/keyboard.ts +0 -208
  81. package/src/graph/search.ts +0 -50
  82. package/src/graph/simulation-worker-url.ts +0 -12
  83. package/src/graph/simulation-worker.ts +0 -278
  84. package/src/graph/simulation.ts +0 -422
  85. package/src/graph/spatial-index.ts +0 -121
  86. package/src/graph/types.ts +0 -47
  87. package/src/graph/worker-protocol.ts +0 -70
  88. package/src/graph/zoom.ts +0 -134
  89. package/src/graph-mount.ts +0 -888
  90. package/src/index.ts +0 -76
  91. package/src/interactions/chart-events.ts +0 -139
  92. package/src/interactions/crosshair.ts +0 -233
  93. package/src/interactions/drag-handler.ts +0 -175
  94. package/src/interactions/editing-drags.ts +0 -649
  95. package/src/interactions/index.ts +0 -27
  96. package/src/interactions/invert.ts +0 -54
  97. package/src/interactions/keyboard-nav.ts +0 -111
  98. package/src/interactions/legend-interaction.ts +0 -46
  99. package/src/interactions/selection.ts +0 -286
  100. package/src/interactions/tooltip-events.ts +0 -72
  101. package/src/map-camera.ts +0 -111
  102. package/src/map-mount.ts +0 -797
  103. package/src/map-renderer.ts +0 -476
  104. package/src/map-transition.ts +0 -99
  105. package/src/measure-text.ts +0 -134
  106. package/src/mount.ts +0 -1434
  107. package/src/pattern-utils.ts +0 -110
  108. package/src/renderers/annotations.ts +0 -388
  109. package/src/renderers/axes.ts +0 -351
  110. package/src/renderers/brand.ts +0 -87
  111. package/src/renderers/chrome.ts +0 -196
  112. package/src/renderers/endpoint-labels.ts +0 -150
  113. package/src/renderers/legend.ts +0 -312
  114. package/src/renderers/marks.ts +0 -561
  115. package/src/renderers/metrics.ts +0 -63
  116. package/src/renderers/svg-dom.ts +0 -40
  117. package/src/renderers/table-cells.ts +0 -446
  118. package/src/resize-observer.ts +0 -46
  119. package/src/sankey-mount.ts +0 -622
  120. package/src/sankey-renderer.ts +0 -614
  121. package/src/series-search.ts +0 -392
  122. package/src/static.ts +0 -251
  123. package/src/story/__tests__/camera-math.test.ts +0 -145
  124. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  125. package/src/story/__tests__/progress-math.test.ts +0 -95
  126. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  127. package/src/story/__tests__/tween.test.ts +0 -159
  128. package/src/story/camera-math.ts +0 -125
  129. package/src/story/create-chart-story.ts +0 -353
  130. package/src/story/crossfade.ts +0 -70
  131. package/src/story/index.ts +0 -39
  132. package/src/story/progress-math.ts +0 -85
  133. package/src/story/resolve-camera-target.ts +0 -119
  134. package/src/story/scroll-driver.ts +0 -206
  135. package/src/story/story-camera.ts +0 -49
  136. package/src/story/tween.ts +0 -112
  137. package/src/story/types.ts +0 -76
  138. package/src/svg-ids.ts +0 -23
  139. package/src/svg-renderer.ts +0 -383
  140. package/src/table-keyboard.ts +0 -266
  141. package/src/table-mount.ts +0 -567
  142. package/src/table-renderer.ts +0 -414
  143. package/src/text-edit-overlay.ts +0 -389
  144. package/src/theme-tokens.ts +0 -51
  145. package/src/tilemap-mount.ts +0 -511
  146. package/src/tilemap-renderer.ts +0 -396
  147. package/src/tooltip.ts +0 -177
  148. package/src/transition.ts +0 -2816
  149. package/src/you-draw-it.ts +0 -480
  150. /package/dist/{chunk-SPILUE5S.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,747 +0,0 @@
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 { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from '@opendata-ai/openchart-core';
16
- import type { GraphRenderState, PositionedEdge, PositionedNode } from './types';
17
-
18
- // ---------------------------------------------------------------------------
19
- // Constants
20
- // ---------------------------------------------------------------------------
21
-
22
- const LABEL_FONT_MIN = 8;
23
- const LABEL_FONT_MAX = 12;
24
- const EDGE_ALPHA_DEFAULT = 0.35;
25
- const EDGE_ALPHA_CONNECTED = 1.0;
26
- const EDGE_ALPHA_DIMMED = 0.05;
27
- const SEARCH_NON_MATCH_ALPHA = 0.15;
28
- const GLOW_NODE_THRESHOLD = 2000;
29
- const GLOW_RADIUS_MULTIPLIER = 1.3;
30
- const GLOW_ALPHA = 0.15;
31
- const CULL_MARGIN = 50;
32
- const TWO_PI = Math.PI * 2;
33
-
34
- /** Minimum node radius in screen pixels. Keeps nodes visible when zoomed out. */
35
- const MIN_SCREEN_RADIUS = 2.5;
36
-
37
- // ---------------------------------------------------------------------------
38
- // Helpers (exported for testing)
39
- // ---------------------------------------------------------------------------
40
-
41
- /**
42
- * Compute label visibility threshold from zoom level.
43
- * At zoom 0.2 (zoomed out): threshold ~1.0 (only top ~5% visible).
44
- * At zoom 2.0+: threshold ~0.0 (all visible).
45
- */
46
- export function labelThreshold(zoom: number): number {
47
- const t = Math.max(0, Math.min(1, (zoom - 0.2) / 1.8));
48
- return 1 - t;
49
- }
50
-
51
- /** Compute visible rect in graph coordinates from canvas size + transform. */
52
- export function visibleRect(
53
- canvasWidth: number,
54
- canvasHeight: number,
55
- transform: { x: number; y: number; k: number },
56
- margin: number = CULL_MARGIN,
57
- ): { minX: number; minY: number; maxX: number; maxY: number } {
58
- const { x, y, k } = transform;
59
- return {
60
- minX: (-x - margin) / k,
61
- minY: (-y - margin) / k,
62
- maxX: (canvasWidth - x + margin) / k,
63
- maxY: (canvasHeight - y + margin) / k,
64
- };
65
- }
66
-
67
- /** Check if a node falls within the visible rect. */
68
- function nodeInView(
69
- node: PositionedNode,
70
- rect: { minX: number; minY: number; maxX: number; maxY: number },
71
- ): boolean {
72
- return (
73
- node.x + node.radius >= rect.minX &&
74
- node.x - node.radius <= rect.maxX &&
75
- node.y + node.radius >= rect.minY &&
76
- node.y - node.radius <= rect.maxY
77
- );
78
- }
79
-
80
- /** Check if an edge has at least one endpoint in view. */
81
- function edgeInView(
82
- edge: PositionedEdge,
83
- rect: { minX: number; minY: number; maxX: number; maxY: number },
84
- ): boolean {
85
- return (
86
- (edge.sourceX >= rect.minX &&
87
- edge.sourceX <= rect.maxX &&
88
- edge.sourceY >= rect.minY &&
89
- edge.sourceY <= rect.maxY) ||
90
- (edge.targetX >= rect.minX &&
91
- edge.targetX <= rect.maxX &&
92
- edge.targetY >= rect.minY &&
93
- edge.targetY <= rect.maxY)
94
- );
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Dash patterns for edge styles
99
- // ---------------------------------------------------------------------------
100
-
101
- const DASH_PATTERNS: Record<string, number[]> = {
102
- solid: [],
103
- dashed: [6, 4],
104
- dotted: [2, 3],
105
- };
106
-
107
- // ---------------------------------------------------------------------------
108
- // GraphCanvasRenderer
109
- // ---------------------------------------------------------------------------
110
-
111
- export class GraphCanvasRenderer {
112
- private canvas: HTMLCanvasElement;
113
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
114
- private ctx: CanvasRenderingContext2D;
115
- private dpr: number;
116
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
117
- private cssWidth = 0;
118
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
119
- private cssHeight = 0;
120
-
121
- constructor(canvas: HTMLCanvasElement) {
122
- this.canvas = canvas;
123
- this.ctx = canvas.getContext('2d')!;
124
- this.dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
125
- }
126
-
127
- /** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
128
- resize(width: number, height: number): void {
129
- this.cssWidth = width;
130
- this.cssHeight = height;
131
- this.canvas.width = width * this.dpr;
132
- this.canvas.height = height * this.dpr;
133
- }
134
-
135
- /** Clear canvas and render the full graph state. */
136
- render(state: GraphRenderState): void {
137
- const { ctx, dpr, cssWidth, cssHeight } = this;
138
- const {
139
- nodes,
140
- edges,
141
- transform,
142
- hoveredNodeId,
143
- hoveredEdgeId,
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 (skip if transparent to let page background show through)
183
- if (theme.colors.background !== 'transparent') {
184
- ctx.fillStyle = theme.colors.background;
185
- ctx.fillRect(0, 0, cssWidth, cssHeight);
186
- }
187
-
188
- ctx.translate(transform.x, transform.y);
189
- ctx.scale(transform.k, transform.k);
190
-
191
- // -- Draw edges (batched by style key) --
192
- this.drawEdgesBatched(
193
- ctx,
194
- visibleEdges,
195
- hasActiveNode,
196
- connectedNodeIds,
197
- isGesturing ? null : searchMatches,
198
- hoveredEdgeId,
199
- );
200
-
201
- // -- Draw nodes (batched by fill color) --
202
- this.drawNodesBatched(
203
- ctx,
204
- visibleNodes,
205
- hoveredNodeId,
206
- selectedNodeIds,
207
- isGesturing ? null : searchMatches,
208
- showGlow,
209
- theme,
210
- minRadius,
211
- );
212
-
213
- // -- Draw labels (skipped during gestures) --
214
- if (!isGesturing) {
215
- this.drawLabels(
216
- ctx,
217
- visibleNodes,
218
- threshold,
219
- hoveredNodeId,
220
- selectedNodeIds,
221
- searchMatches,
222
- transform.k,
223
- theme,
224
- );
225
- }
226
-
227
- ctx.restore();
228
-
229
- // Brand watermark in screen coordinates (unaffected by pan/zoom)
230
- if (state.watermark) {
231
- this.drawBrand(ctx, cssWidth, cssHeight, theme);
232
- }
233
- }
234
-
235
- // -------------------------------------------------------------------------
236
- // Brand rendering
237
- // -------------------------------------------------------------------------
238
-
239
- private drawBrand(
240
- ctx: CanvasRenderingContext2D,
241
- w: number,
242
- h: number,
243
- theme: GraphRenderState['theme'],
244
- ): void {
245
- if (w < BRAND_MIN_WIDTH) return;
246
- const { dpr } = this;
247
- ctx.save();
248
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
249
- const padding = theme.spacing.padding;
250
- const x = w - padding;
251
- const y = h - padding;
252
- ctx.font = `600 ${BRAND_FONT_SIZE}px ${theme.fonts.family}`;
253
- ctx.fillStyle = theme.colors.axis;
254
- ctx.globalAlpha = 0.55;
255
- ctx.textAlign = 'right';
256
- ctx.textBaseline = 'alphabetic';
257
- ctx.fillText('tryOpenData.ai', x, y);
258
- ctx.restore();
259
- }
260
-
261
- // -------------------------------------------------------------------------
262
- // Batched edge drawing
263
- // -------------------------------------------------------------------------
264
-
265
- private drawEdgesBatched(
266
- ctx: CanvasRenderingContext2D,
267
- edges: PositionedEdge[],
268
- hasActiveNode: boolean,
269
- connectedNodeIds: Set<string>,
270
- searchMatches: Set<string> | null,
271
- hoveredEdgeId: string | null,
272
- ): void {
273
- // Classify edges by alpha level, then batch by visual style within each level
274
- const dimmedEdges: PositionedEdge[] = [];
275
- const defaultEdges: PositionedEdge[] = [];
276
- const connectedEdges: PositionedEdge[] = [];
277
- let hoveredEdge: PositionedEdge | null = null;
278
-
279
- for (const edge of edges) {
280
- const edgeId = `${edge.source}->${edge.target}`;
281
- if (edgeId === hoveredEdgeId) {
282
- hoveredEdge = edge;
283
- continue; // Draw hovered edge last, on top
284
- }
285
-
286
- const isConnected =
287
- hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
288
- const isDimmed = hasActiveNode && !isConnected;
289
-
290
- if (isConnected) {
291
- connectedEdges.push(edge);
292
- } else if (isDimmed) {
293
- dimmedEdges.push(edge);
294
- } else {
295
- defaultEdges.push(edge);
296
- }
297
- }
298
-
299
- // Draw dimmed first, then default, then connected (on top)
300
- this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
301
- this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
302
- this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
303
-
304
- // Draw hovered edge on top with highlight
305
- if (hoveredEdge) {
306
- const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
307
- ctx.setLineDash(dash);
308
- ctx.strokeStyle = hoveredEdge.stroke;
309
- ctx.lineWidth = hoveredEdge.strokeWidth * 2;
310
- ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
311
- ctx.beginPath();
312
- ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
313
- ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
314
- ctx.stroke();
315
- ctx.setLineDash([]);
316
- ctx.globalAlpha = 1;
317
- }
318
- }
319
-
320
- /**
321
- * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
322
- * When search is inactive, all edges of the same style are drawn in a single path.
323
- * When search is active, edges split by search-match status for alpha dimming.
324
- */
325
- private drawEdgeGroupBatched(
326
- ctx: CanvasRenderingContext2D,
327
- edges: PositionedEdge[],
328
- alpha: number,
329
- searchMatches: Set<string> | null,
330
- ): void {
331
- if (edges.length === 0) return;
332
-
333
- // Group by visual key: stroke + strokeWidth + style
334
- const groups = new Map<string, PositionedEdge[]>();
335
- for (const edge of edges) {
336
- const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
337
- let group = groups.get(key);
338
- if (!group) {
339
- group = [];
340
- groups.set(key, group);
341
- }
342
- group.push(edge);
343
- }
344
-
345
- for (const [, group] of groups) {
346
- const sample = group[0];
347
- const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
348
- ctx.setLineDash(dash);
349
- ctx.strokeStyle = sample.stroke;
350
- ctx.lineWidth = sample.strokeWidth;
351
-
352
- if (!searchMatches) {
353
- // Fast path: single batched path for all edges in this group
354
- ctx.globalAlpha = alpha;
355
- ctx.beginPath();
356
- for (const edge of group) {
357
- ctx.moveTo(edge.sourceX, edge.sourceY);
358
- ctx.lineTo(edge.targetX, edge.targetY);
359
- }
360
- ctx.stroke();
361
- } else {
362
- // Search active: split into matched and non-matched batches
363
- ctx.globalAlpha = alpha;
364
- ctx.beginPath();
365
- let hasMatched = false;
366
-
367
- const nonMatchPath: PositionedEdge[] = [];
368
-
369
- for (const edge of group) {
370
- const srcMatch = searchMatches.has(edge.source);
371
- const tgtMatch = searchMatches.has(edge.target);
372
- if (srcMatch || tgtMatch) {
373
- ctx.moveTo(edge.sourceX, edge.sourceY);
374
- ctx.lineTo(edge.targetX, edge.targetY);
375
- hasMatched = true;
376
- } else {
377
- nonMatchPath.push(edge);
378
- }
379
- }
380
- if (hasMatched) ctx.stroke();
381
-
382
- // Draw non-matching edges dimmed
383
- if (nonMatchPath.length > 0) {
384
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * alpha;
385
- ctx.beginPath();
386
- for (const edge of nonMatchPath) {
387
- ctx.moveTo(edge.sourceX, edge.sourceY);
388
- ctx.lineTo(edge.targetX, edge.targetY);
389
- }
390
- ctx.stroke();
391
- }
392
- }
393
- }
394
-
395
- ctx.setLineDash([]);
396
- ctx.globalAlpha = 1;
397
- }
398
-
399
- // -------------------------------------------------------------------------
400
- // Batched node drawing
401
- // -------------------------------------------------------------------------
402
-
403
- private drawNodesBatched(
404
- ctx: CanvasRenderingContext2D,
405
- nodes: PositionedNode[],
406
- hoveredNodeId: string | null,
407
- selectedNodeIds: Set<string>,
408
- searchMatches: Set<string> | null,
409
- showGlow: boolean,
410
- theme: GraphRenderState['theme'],
411
- minRadius: number,
412
- ): void {
413
- // Separate special nodes (hovered/selected) from bulk nodes.
414
- // Special nodes need individual treatment; bulk nodes get batched by color.
415
- const bulkNodes: PositionedNode[] = [];
416
- const specialNodes: PositionedNode[] = [];
417
-
418
- for (const node of nodes) {
419
- if (node.id === hoveredNodeId || selectedNodeIds.has(node.id)) {
420
- specialNodes.push(node);
421
- } else {
422
- bulkNodes.push(node);
423
- }
424
- }
425
-
426
- // Helper: effective radius clamped to minimum screen size
427
- const r = (node: PositionedNode) => Math.max(node.radius, minRadius);
428
-
429
- // --- Glow pass (dark mode only, before fills) ---
430
- if (showGlow) {
431
- this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
432
- }
433
-
434
- // --- Bulk fill pass: batch by fill color ---
435
- const fillGroups = new Map<string, PositionedNode[]>();
436
- for (const node of bulkNodes) {
437
- let group = fillGroups.get(node.fill);
438
- if (!group) {
439
- group = [];
440
- fillGroups.set(node.fill, group);
441
- }
442
- group.push(node);
443
- }
444
-
445
- if (!searchMatches) {
446
- // Fast path: no search dimming
447
- ctx.globalAlpha = 1;
448
- for (const [fill, group] of fillGroups) {
449
- ctx.fillStyle = fill;
450
- ctx.beginPath();
451
- for (const node of group) {
452
- const nr = r(node);
453
- ctx.moveTo(node.x + nr, node.y);
454
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
455
- }
456
- ctx.fill();
457
- }
458
- } else {
459
- // Search active: split each color group into matched/dimmed batches
460
- for (const [fill, group] of fillGroups) {
461
- ctx.fillStyle = fill;
462
-
463
- // Matched nodes
464
- ctx.globalAlpha = 1;
465
- ctx.beginPath();
466
- let hasMatched = false;
467
- const dimmedNodes: PositionedNode[] = [];
468
-
469
- for (const node of group) {
470
- if (searchMatches.has(node.id)) {
471
- const nr = r(node);
472
- ctx.moveTo(node.x + nr, node.y);
473
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
474
- hasMatched = true;
475
- } else {
476
- dimmedNodes.push(node);
477
- }
478
- }
479
- if (hasMatched) ctx.fill();
480
-
481
- // Dimmed nodes
482
- if (dimmedNodes.length > 0) {
483
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
484
- ctx.beginPath();
485
- for (const node of dimmedNodes) {
486
- const nr = r(node);
487
- ctx.moveTo(node.x + nr, node.y);
488
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
489
- }
490
- ctx.fill();
491
- }
492
- }
493
- }
494
-
495
- // --- Bulk stroke pass: batch by stroke color ---
496
- const strokeGroups = new Map<string, PositionedNode[]>();
497
- for (const node of bulkNodes) {
498
- const key = `${node.stroke}|${node.strokeWidth}`;
499
- let group = strokeGroups.get(key);
500
- if (!group) {
501
- group = [];
502
- strokeGroups.set(key, group);
503
- }
504
- group.push(node);
505
- }
506
-
507
- for (const [key, group] of strokeGroups) {
508
- const [stroke, widthStr] = key.split('|');
509
- ctx.strokeStyle = stroke;
510
- ctx.lineWidth = parseFloat(widthStr);
511
-
512
- if (!searchMatches) {
513
- ctx.globalAlpha = 1;
514
- ctx.beginPath();
515
- for (const node of group) {
516
- const nr = r(node);
517
- ctx.moveTo(node.x + nr, node.y);
518
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
519
- }
520
- ctx.stroke();
521
- } else {
522
- // Split matched/dimmed for strokes too
523
- ctx.globalAlpha = 1;
524
- ctx.beginPath();
525
- let hasMatched = false;
526
- const dimmedNodes: PositionedNode[] = [];
527
-
528
- for (const node of group) {
529
- if (searchMatches.has(node.id)) {
530
- const nr = r(node);
531
- ctx.moveTo(node.x + nr, node.y);
532
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
533
- hasMatched = true;
534
- } else {
535
- dimmedNodes.push(node);
536
- }
537
- }
538
- if (hasMatched) ctx.stroke();
539
-
540
- if (dimmedNodes.length > 0) {
541
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
542
- ctx.beginPath();
543
- for (const node of dimmedNodes) {
544
- const nr = r(node);
545
- ctx.moveTo(node.x + nr, node.y);
546
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
547
- }
548
- ctx.stroke();
549
- }
550
- }
551
- }
552
-
553
- // --- Special nodes (hovered/selected) drawn individually ---
554
- for (const node of specialNodes) {
555
- const isHovered = node.id === hoveredNodeId;
556
- const isSelected = selectedNodeIds.has(node.id);
557
- const dimmed = searchMatches !== null && !searchMatches.has(node.id);
558
- const baseRadius = Math.max(node.radius, minRadius);
559
- const radius = isHovered ? baseRadius * 1.15 : baseRadius;
560
-
561
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
562
-
563
- // Glow for special nodes
564
- if (showGlow && !dimmed) {
565
- ctx.beginPath();
566
- ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
567
- ctx.fillStyle = node.fill;
568
- ctx.globalAlpha = GLOW_ALPHA;
569
- ctx.fill();
570
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
571
- }
572
-
573
- // Fill
574
- ctx.beginPath();
575
- ctx.arc(node.x, node.y, radius, 0, TWO_PI);
576
- ctx.fillStyle = isHovered ? brighten(node.fill) : node.fill;
577
- ctx.fill();
578
-
579
- // Stroke
580
- ctx.strokeStyle = node.stroke;
581
- ctx.lineWidth = node.strokeWidth;
582
- ctx.stroke();
583
-
584
- // Selection ring
585
- if (isSelected) {
586
- ctx.beginPath();
587
- ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
588
- ctx.strokeStyle = theme.colors.categorical[0] ?? '#3b82f6';
589
- ctx.lineWidth = 2;
590
- ctx.stroke();
591
- }
592
- }
593
-
594
- ctx.globalAlpha = 1;
595
- }
596
-
597
- /** Batch glow circles by fill color. */
598
- private drawGlowBatched(
599
- ctx: CanvasRenderingContext2D,
600
- nodes: PositionedNode[],
601
- searchMatches: Set<string> | null,
602
- minRadius: number,
603
- ): void {
604
- const glowGroups = new Map<string, PositionedNode[]>();
605
- for (const node of nodes) {
606
- if (searchMatches && !searchMatches.has(node.id)) continue;
607
- let group = glowGroups.get(node.fill);
608
- if (!group) {
609
- group = [];
610
- glowGroups.set(node.fill, group);
611
- }
612
- group.push(node);
613
- }
614
-
615
- ctx.globalAlpha = GLOW_ALPHA;
616
- for (const [fill, group] of glowGroups) {
617
- ctx.fillStyle = fill;
618
- ctx.beginPath();
619
- for (const node of group) {
620
- const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
621
- ctx.moveTo(node.x + gr, node.y);
622
- ctx.arc(node.x, node.y, gr, 0, TWO_PI);
623
- }
624
- ctx.fill();
625
- }
626
- ctx.globalAlpha = 1;
627
- }
628
-
629
- // -------------------------------------------------------------------------
630
- // Labels (drawn individually, skipped during gestures)
631
- // -------------------------------------------------------------------------
632
-
633
- private drawLabels(
634
- ctx: CanvasRenderingContext2D,
635
- nodes: PositionedNode[],
636
- threshold: number,
637
- hoveredNodeId: string | null,
638
- selectedNodeIds: Set<string>,
639
- searchMatches: Set<string> | null,
640
- zoom: number,
641
- theme: GraphRenderState['theme'],
642
- ): void {
643
- // Font size inversely scaled by zoom, clamped to readable range
644
- const rawSize = 10 / zoom;
645
- const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
646
-
647
- ctx.font = `${fontSize}px ${theme.fonts.family}`;
648
- ctx.textAlign = 'center';
649
- ctx.textBaseline = 'top';
650
-
651
- for (const node of nodes) {
652
- if (!node.label) continue;
653
-
654
- const isHovered = node.id === hoveredNodeId;
655
- const isSelected = selectedNodeIds.has(node.id);
656
- const forced = isHovered || isSelected;
657
- const dimmed = searchMatches !== null && !searchMatches.has(node.id);
658
-
659
- // LOD: skip labels below threshold unless forced
660
- if (!forced && node.labelPriority < threshold) continue;
661
-
662
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
663
-
664
- const labelY = node.y + node.radius + 3;
665
-
666
- // Halo for readability: stroke behind text in the background color
667
- // so labels stay legible over edges and other nodes.
668
- if (theme.colors.background !== 'transparent') {
669
- ctx.strokeStyle = theme.colors.background;
670
- } else {
671
- // Transparent bg: infer page background from text luminance.
672
- // Light text = dark page, dark text = light page.
673
- ctx.strokeStyle = isLightColor(theme.colors.text)
674
- ? 'rgba(0, 0, 0, 0.7)'
675
- : 'rgba(255, 255, 255, 0.85)';
676
- }
677
- ctx.lineWidth = 3;
678
- ctx.lineJoin = 'round';
679
- ctx.miterLimit = 2;
680
- ctx.strokeText(node.label, node.x, labelY);
681
-
682
- ctx.fillStyle = theme.colors.text;
683
- ctx.fillText(node.label, node.x, labelY);
684
- }
685
-
686
- ctx.globalAlpha = 1;
687
- }
688
- }
689
-
690
- // ---------------------------------------------------------------------------
691
- // Color helpers
692
- // ---------------------------------------------------------------------------
693
-
694
- /**
695
- * Brighten a hex/rgb color by ~20% for hover effect.
696
- * Quick and dirty approach: parse hex, lighten each channel.
697
- */
698
- function brighten(color: string): string {
699
- // Handle rgb(r,g,b) or rgb(r, g, b)
700
- const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
701
- if (rgbMatch) {
702
- const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
703
- const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
704
- const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
705
- return `rgb(${r},${g},${b})`;
706
- }
707
-
708
- // Handle hex colors (#rgb and #rrggbb)
709
- const hex = color.replace('#', '');
710
- const full =
711
- hex.length === 3
712
- ? hex
713
- .split('')
714
- .map((c) => c + c)
715
- .join('')
716
- : hex;
717
-
718
- if (full.length === 6) {
719
- const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
720
- const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
721
- const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
722
- return `rgb(${r},${g},${b})`;
723
- }
724
-
725
- return color;
726
- }
727
-
728
- /**
729
- * Returns true if a color is perceptually light (luminance > 0.5).
730
- * Used to pick a contrasting halo color for labels on transparent backgrounds.
731
- */
732
- function isLightColor(color: string): boolean {
733
- const hex = color.replace('#', '');
734
- const full =
735
- hex.length === 3
736
- ? hex
737
- .split('')
738
- .map((c) => c + c)
739
- .join('')
740
- : hex;
741
- if (full.length !== 6) return false;
742
- const r = parseInt(full.slice(0, 2), 16) / 255;
743
- const g = parseInt(full.slice(2, 4), 16) / 255;
744
- const b = parseInt(full.slice(4, 6), 16) / 255;
745
- const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
746
- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) > 0.5;
747
- }