@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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 (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.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 +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.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
- }