@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
package/dist/index.js ADDED
@@ -0,0 +1,4745 @@
1
+ // src/export.ts
2
+ function exportSVG(svgElement) {
3
+ const serializer = new XMLSerializer();
4
+ return serializer.serializeToString(svgElement);
5
+ }
6
+ async function exportPNG(svgElement, options) {
7
+ const dpi = options?.dpi ?? 2;
8
+ const svgString = exportSVG(svgElement);
9
+ const width = parseFloat(svgElement.getAttribute("width") || "600");
10
+ const height = parseFloat(svgElement.getAttribute("height") || "400");
11
+ const canvas = document.createElement("canvas");
12
+ canvas.width = width * dpi;
13
+ canvas.height = height * dpi;
14
+ const ctx = canvas.getContext("2d");
15
+ if (!ctx) {
16
+ throw new Error("Canvas 2D context not available");
17
+ }
18
+ ctx.scale(dpi, dpi);
19
+ const img = new Image();
20
+ const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
21
+ const url = URL.createObjectURL(blob);
22
+ return new Promise((resolve, reject) => {
23
+ img.onload = () => {
24
+ ctx.drawImage(img, 0, 0);
25
+ URL.revokeObjectURL(url);
26
+ canvas.toBlob((result) => {
27
+ if (result) {
28
+ resolve(result);
29
+ } else {
30
+ reject(new Error("Canvas toBlob returned null"));
31
+ }
32
+ }, "image/png");
33
+ };
34
+ img.onerror = () => {
35
+ URL.revokeObjectURL(url);
36
+ reject(new Error("Failed to load SVG as image"));
37
+ };
38
+ img.src = url;
39
+ });
40
+ }
41
+ function exportCSV(data) {
42
+ if (data.length === 0) return "";
43
+ const headers = Object.keys(data[0]);
44
+ const rows = [headers.map(csvEscape).join(",")];
45
+ for (const row of data) {
46
+ const values = headers.map((h) => csvEscape(String(row[h] ?? "")));
47
+ rows.push(values.join(","));
48
+ }
49
+ return rows.join("\n");
50
+ }
51
+ function csvEscape(value) {
52
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
53
+ return `"${value.replace(/"/g, '""')}"`;
54
+ }
55
+ return value;
56
+ }
57
+
58
+ // src/graph/simulation-worker-url.ts
59
+ var workerUrl = new URL("./simulation-worker.ts", import.meta.url);
60
+ function createSimulationWorker() {
61
+ return new Worker(workerUrl, { type: "module" });
62
+ }
63
+
64
+ // src/graph-mount.ts
65
+ import { compileGraph } from "@opendata-ai/openchart-engine";
66
+
67
+ // src/graph/canvas-renderer.ts
68
+ var LABEL_FONT_MIN = 10;
69
+ var LABEL_FONT_MAX = 14;
70
+ var EDGE_ALPHA_DEFAULT = 0.35;
71
+ var EDGE_ALPHA_CONNECTED = 1;
72
+ var EDGE_ALPHA_DIMMED = 0.05;
73
+ var SEARCH_NON_MATCH_ALPHA = 0.15;
74
+ var GLOW_NODE_THRESHOLD = 2e3;
75
+ var GLOW_RADIUS_MULTIPLIER = 1.5;
76
+ var GLOW_ALPHA = 0.2;
77
+ var CULL_MARGIN = 50;
78
+ var TWO_PI = Math.PI * 2;
79
+ var MIN_SCREEN_RADIUS = 2.5;
80
+ function labelThreshold(zoom) {
81
+ const t = Math.max(0, Math.min(1, (zoom - 0.2) / 1.8));
82
+ return 1 - t;
83
+ }
84
+ function visibleRect(canvasWidth, canvasHeight, transform, margin = CULL_MARGIN) {
85
+ const { x, y, k } = transform;
86
+ return {
87
+ minX: (-x - margin) / k,
88
+ minY: (-y - margin) / k,
89
+ maxX: (canvasWidth - x + margin) / k,
90
+ maxY: (canvasHeight - y + margin) / k
91
+ };
92
+ }
93
+ function nodeInView(node, rect) {
94
+ return node.x + node.radius >= rect.minX && node.x - node.radius <= rect.maxX && node.y + node.radius >= rect.minY && node.y - node.radius <= rect.maxY;
95
+ }
96
+ function edgeInView(edge, rect) {
97
+ return edge.sourceX >= rect.minX && edge.sourceX <= rect.maxX && edge.sourceY >= rect.minY && edge.sourceY <= rect.maxY || edge.targetX >= rect.minX && edge.targetX <= rect.maxX && edge.targetY >= rect.minY && edge.targetY <= rect.maxY;
98
+ }
99
+ var DASH_PATTERNS = {
100
+ solid: [],
101
+ dashed: [6, 4],
102
+ dotted: [2, 3]
103
+ };
104
+ var GraphCanvasRenderer = class {
105
+ canvas;
106
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
107
+ ctx;
108
+ dpr;
109
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
110
+ cssWidth = 0;
111
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
112
+ cssHeight = 0;
113
+ constructor(canvas) {
114
+ this.canvas = canvas;
115
+ this.ctx = canvas.getContext("2d");
116
+ this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
117
+ }
118
+ /** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
119
+ resize(width, height) {
120
+ this.cssWidth = width;
121
+ this.cssHeight = height;
122
+ this.canvas.width = width * this.dpr;
123
+ this.canvas.height = height * this.dpr;
124
+ this.canvas.style.width = `${width}px`;
125
+ this.canvas.style.height = `${height}px`;
126
+ }
127
+ /** Clear canvas and render the full graph state. */
128
+ render(state) {
129
+ const { ctx, dpr, cssWidth, cssHeight } = this;
130
+ const {
131
+ nodes,
132
+ edges,
133
+ transform,
134
+ hoveredNodeId,
135
+ selectedNodeIds,
136
+ adjacencyMap,
137
+ theme,
138
+ searchMatches,
139
+ isGesturing
140
+ } = state;
141
+ const hasActiveNode = hoveredNodeId !== null || selectedNodeIds.size > 0;
142
+ const activeNodeIds = /* @__PURE__ */ new Set();
143
+ if (hoveredNodeId) activeNodeIds.add(hoveredNodeId);
144
+ for (const id of selectedNodeIds) activeNodeIds.add(id);
145
+ const connectedNodeIds = /* @__PURE__ */ new Set();
146
+ for (const id of activeNodeIds) {
147
+ connectedNodeIds.add(id);
148
+ const neighbors = adjacencyMap.get(id);
149
+ if (neighbors) {
150
+ for (const nid of neighbors) connectedNodeIds.add(nid);
151
+ }
152
+ }
153
+ const rect = visibleRect(cssWidth, cssHeight, transform);
154
+ const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
155
+ const visibleEdges = edges.filter((e) => edgeInView(e, rect));
156
+ const isDark = theme.isDark;
157
+ const showGlow = isDark && !isGesturing && visibleNodes.length < GLOW_NODE_THRESHOLD;
158
+ const threshold = labelThreshold(transform.k);
159
+ const minRadius = MIN_SCREEN_RADIUS / transform.k;
160
+ ctx.save();
161
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
162
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
163
+ ctx.fillStyle = theme.colors.background;
164
+ ctx.fillRect(0, 0, cssWidth, cssHeight);
165
+ ctx.translate(transform.x, transform.y);
166
+ ctx.scale(transform.k, transform.k);
167
+ this.drawEdgesBatched(
168
+ ctx,
169
+ visibleEdges,
170
+ hasActiveNode,
171
+ connectedNodeIds,
172
+ isGesturing ? null : searchMatches
173
+ );
174
+ this.drawNodesBatched(
175
+ ctx,
176
+ visibleNodes,
177
+ hoveredNodeId,
178
+ selectedNodeIds,
179
+ isGesturing ? null : searchMatches,
180
+ showGlow,
181
+ theme,
182
+ minRadius
183
+ );
184
+ if (!isGesturing) {
185
+ this.drawLabels(
186
+ ctx,
187
+ visibleNodes,
188
+ threshold,
189
+ hoveredNodeId,
190
+ selectedNodeIds,
191
+ searchMatches,
192
+ transform.k,
193
+ theme
194
+ );
195
+ }
196
+ ctx.restore();
197
+ }
198
+ // -------------------------------------------------------------------------
199
+ // Batched edge drawing
200
+ // -------------------------------------------------------------------------
201
+ drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches) {
202
+ const dimmedEdges = [];
203
+ const defaultEdges = [];
204
+ const connectedEdges = [];
205
+ for (const edge of edges) {
206
+ const isConnected = hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
207
+ const isDimmed = hasActiveNode && !isConnected;
208
+ if (isConnected) {
209
+ connectedEdges.push(edge);
210
+ } else if (isDimmed) {
211
+ dimmedEdges.push(edge);
212
+ } else {
213
+ defaultEdges.push(edge);
214
+ }
215
+ }
216
+ this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
217
+ this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
218
+ this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
219
+ }
220
+ /**
221
+ * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
222
+ * When search is inactive, all edges of the same style are drawn in a single path.
223
+ * When search is active, edges split by search-match status for alpha dimming.
224
+ */
225
+ drawEdgeGroupBatched(ctx, edges, alpha, searchMatches) {
226
+ if (edges.length === 0) return;
227
+ const groups = /* @__PURE__ */ new Map();
228
+ for (const edge of edges) {
229
+ const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
230
+ let group = groups.get(key);
231
+ if (!group) {
232
+ group = [];
233
+ groups.set(key, group);
234
+ }
235
+ group.push(edge);
236
+ }
237
+ for (const [, group] of groups) {
238
+ const sample = group[0];
239
+ const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
240
+ ctx.setLineDash(dash);
241
+ ctx.strokeStyle = sample.stroke;
242
+ ctx.lineWidth = sample.strokeWidth;
243
+ if (!searchMatches) {
244
+ ctx.globalAlpha = alpha;
245
+ ctx.beginPath();
246
+ for (const edge of group) {
247
+ ctx.moveTo(edge.sourceX, edge.sourceY);
248
+ ctx.lineTo(edge.targetX, edge.targetY);
249
+ }
250
+ ctx.stroke();
251
+ } else {
252
+ ctx.globalAlpha = alpha;
253
+ ctx.beginPath();
254
+ let hasMatched = false;
255
+ const nonMatchPath = [];
256
+ for (const edge of group) {
257
+ const srcMatch = searchMatches.has(edge.source);
258
+ const tgtMatch = searchMatches.has(edge.target);
259
+ if (srcMatch || tgtMatch) {
260
+ ctx.moveTo(edge.sourceX, edge.sourceY);
261
+ ctx.lineTo(edge.targetX, edge.targetY);
262
+ hasMatched = true;
263
+ } else {
264
+ nonMatchPath.push(edge);
265
+ }
266
+ }
267
+ if (hasMatched) ctx.stroke();
268
+ if (nonMatchPath.length > 0) {
269
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * alpha;
270
+ ctx.beginPath();
271
+ for (const edge of nonMatchPath) {
272
+ ctx.moveTo(edge.sourceX, edge.sourceY);
273
+ ctx.lineTo(edge.targetX, edge.targetY);
274
+ }
275
+ ctx.stroke();
276
+ }
277
+ }
278
+ }
279
+ ctx.setLineDash([]);
280
+ ctx.globalAlpha = 1;
281
+ }
282
+ // -------------------------------------------------------------------------
283
+ // Batched node drawing
284
+ // -------------------------------------------------------------------------
285
+ drawNodesBatched(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, showGlow, theme, minRadius) {
286
+ const bulkNodes = [];
287
+ const specialNodes = [];
288
+ for (const node of nodes) {
289
+ if (node.id === hoveredNodeId || selectedNodeIds.has(node.id)) {
290
+ specialNodes.push(node);
291
+ } else {
292
+ bulkNodes.push(node);
293
+ }
294
+ }
295
+ const r = (node) => Math.max(node.radius, minRadius);
296
+ if (showGlow) {
297
+ this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
298
+ }
299
+ const fillGroups = /* @__PURE__ */ new Map();
300
+ for (const node of bulkNodes) {
301
+ let group = fillGroups.get(node.fill);
302
+ if (!group) {
303
+ group = [];
304
+ fillGroups.set(node.fill, group);
305
+ }
306
+ group.push(node);
307
+ }
308
+ if (!searchMatches) {
309
+ ctx.globalAlpha = 1;
310
+ for (const [fill, group] of fillGroups) {
311
+ ctx.fillStyle = fill;
312
+ ctx.beginPath();
313
+ for (const node of group) {
314
+ const nr = r(node);
315
+ ctx.moveTo(node.x + nr, node.y);
316
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
317
+ }
318
+ ctx.fill();
319
+ }
320
+ } else {
321
+ for (const [fill, group] of fillGroups) {
322
+ ctx.fillStyle = fill;
323
+ ctx.globalAlpha = 1;
324
+ ctx.beginPath();
325
+ let hasMatched = false;
326
+ const dimmedNodes = [];
327
+ for (const node of group) {
328
+ if (searchMatches.has(node.id)) {
329
+ const nr = r(node);
330
+ ctx.moveTo(node.x + nr, node.y);
331
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
332
+ hasMatched = true;
333
+ } else {
334
+ dimmedNodes.push(node);
335
+ }
336
+ }
337
+ if (hasMatched) ctx.fill();
338
+ if (dimmedNodes.length > 0) {
339
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
340
+ ctx.beginPath();
341
+ for (const node of dimmedNodes) {
342
+ const nr = r(node);
343
+ ctx.moveTo(node.x + nr, node.y);
344
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
345
+ }
346
+ ctx.fill();
347
+ }
348
+ }
349
+ }
350
+ const strokeGroups = /* @__PURE__ */ new Map();
351
+ for (const node of bulkNodes) {
352
+ const key = `${node.stroke}|${node.strokeWidth}`;
353
+ let group = strokeGroups.get(key);
354
+ if (!group) {
355
+ group = [];
356
+ strokeGroups.set(key, group);
357
+ }
358
+ group.push(node);
359
+ }
360
+ for (const [key, group] of strokeGroups) {
361
+ const [stroke, widthStr] = key.split("|");
362
+ ctx.strokeStyle = stroke;
363
+ ctx.lineWidth = parseFloat(widthStr);
364
+ if (!searchMatches) {
365
+ ctx.globalAlpha = 1;
366
+ ctx.beginPath();
367
+ for (const node of group) {
368
+ const nr = r(node);
369
+ ctx.moveTo(node.x + nr, node.y);
370
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
371
+ }
372
+ ctx.stroke();
373
+ } else {
374
+ ctx.globalAlpha = 1;
375
+ ctx.beginPath();
376
+ let hasMatched = false;
377
+ const dimmedNodes = [];
378
+ for (const node of group) {
379
+ if (searchMatches.has(node.id)) {
380
+ const nr = r(node);
381
+ ctx.moveTo(node.x + nr, node.y);
382
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
383
+ hasMatched = true;
384
+ } else {
385
+ dimmedNodes.push(node);
386
+ }
387
+ }
388
+ if (hasMatched) ctx.stroke();
389
+ if (dimmedNodes.length > 0) {
390
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
391
+ ctx.beginPath();
392
+ for (const node of dimmedNodes) {
393
+ const nr = r(node);
394
+ ctx.moveTo(node.x + nr, node.y);
395
+ ctx.arc(node.x, node.y, nr, 0, TWO_PI);
396
+ }
397
+ ctx.stroke();
398
+ }
399
+ }
400
+ }
401
+ for (const node of specialNodes) {
402
+ const isHovered = node.id === hoveredNodeId;
403
+ const isSelected = selectedNodeIds.has(node.id);
404
+ const dimmed = searchMatches !== null && !searchMatches.has(node.id);
405
+ const baseRadius = Math.max(node.radius, minRadius);
406
+ const radius = isHovered ? baseRadius * 1.15 : baseRadius;
407
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
408
+ if (showGlow && !dimmed) {
409
+ ctx.beginPath();
410
+ ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
411
+ ctx.fillStyle = node.fill;
412
+ ctx.globalAlpha = GLOW_ALPHA;
413
+ ctx.fill();
414
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
415
+ }
416
+ ctx.beginPath();
417
+ ctx.arc(node.x, node.y, radius, 0, TWO_PI);
418
+ ctx.fillStyle = isHovered ? brighten(node.fill) : node.fill;
419
+ ctx.fill();
420
+ ctx.strokeStyle = node.stroke;
421
+ ctx.lineWidth = node.strokeWidth;
422
+ ctx.stroke();
423
+ if (isSelected) {
424
+ ctx.beginPath();
425
+ ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
426
+ ctx.strokeStyle = theme.colors.categorical[0] ?? "#3b82f6";
427
+ ctx.lineWidth = 2;
428
+ ctx.stroke();
429
+ }
430
+ }
431
+ ctx.globalAlpha = 1;
432
+ }
433
+ /** Batch glow circles by fill color. */
434
+ drawGlowBatched(ctx, nodes, searchMatches, minRadius) {
435
+ const glowGroups = /* @__PURE__ */ new Map();
436
+ for (const node of nodes) {
437
+ if (searchMatches && !searchMatches.has(node.id)) continue;
438
+ let group = glowGroups.get(node.fill);
439
+ if (!group) {
440
+ group = [];
441
+ glowGroups.set(node.fill, group);
442
+ }
443
+ group.push(node);
444
+ }
445
+ ctx.globalAlpha = GLOW_ALPHA;
446
+ for (const [fill, group] of glowGroups) {
447
+ ctx.fillStyle = fill;
448
+ ctx.beginPath();
449
+ for (const node of group) {
450
+ const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
451
+ ctx.moveTo(node.x + gr, node.y);
452
+ ctx.arc(node.x, node.y, gr, 0, TWO_PI);
453
+ }
454
+ ctx.fill();
455
+ }
456
+ ctx.globalAlpha = 1;
457
+ }
458
+ // -------------------------------------------------------------------------
459
+ // Labels (drawn individually, skipped during gestures)
460
+ // -------------------------------------------------------------------------
461
+ drawLabels(ctx, nodes, threshold, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme) {
462
+ const rawSize = 12 / zoom;
463
+ const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
464
+ ctx.font = `${fontSize}px ${theme.fonts.family}`;
465
+ ctx.textAlign = "center";
466
+ ctx.textBaseline = "top";
467
+ for (const node of nodes) {
468
+ if (!node.label) continue;
469
+ const isHovered = node.id === hoveredNodeId;
470
+ const isSelected = selectedNodeIds.has(node.id);
471
+ const forced = isHovered || isSelected;
472
+ const dimmed = searchMatches !== null && !searchMatches.has(node.id);
473
+ if (!forced && node.labelPriority < threshold) continue;
474
+ ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
475
+ const labelY = node.y + node.radius + 3;
476
+ ctx.strokeStyle = theme.colors.background;
477
+ ctx.lineWidth = 3;
478
+ ctx.lineJoin = "round";
479
+ ctx.strokeText(node.label, node.x, labelY);
480
+ ctx.fillStyle = theme.colors.text;
481
+ ctx.fillText(node.label, node.x, labelY);
482
+ }
483
+ ctx.globalAlpha = 1;
484
+ }
485
+ };
486
+ function brighten(color) {
487
+ const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
488
+ if (rgbMatch) {
489
+ const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
490
+ const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
491
+ const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
492
+ return `rgb(${r},${g},${b})`;
493
+ }
494
+ const hex = color.replace("#", "");
495
+ const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
496
+ if (full.length === 6) {
497
+ const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
498
+ const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
499
+ const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
500
+ return `rgb(${r},${g},${b})`;
501
+ }
502
+ return color;
503
+ }
504
+
505
+ // src/graph/zoom.ts
506
+ var ZoomTransform = class _ZoomTransform {
507
+ constructor(x, y, k) {
508
+ this.x = x;
509
+ this.y = y;
510
+ this.k = k;
511
+ }
512
+ /** Convert screen coordinates to graph coordinates. */
513
+ screenToGraph(sx, sy) {
514
+ return {
515
+ x: (sx - this.x) / this.k,
516
+ y: (sy - this.y) / this.k
517
+ };
518
+ }
519
+ /** Convert graph coordinates to screen coordinates. */
520
+ graphToScreen(gx, gy) {
521
+ return {
522
+ x: gx * this.k + this.x,
523
+ y: gy * this.k + this.y
524
+ };
525
+ }
526
+ /**
527
+ * Zoom to a target scale, keeping the given screen-space pivot
528
+ * point fixed (content under the cursor stays under the cursor).
529
+ */
530
+ zoomAt(targetK, pivotX, pivotY) {
531
+ const gx = (pivotX - this.x) / this.k;
532
+ const gy = (pivotY - this.y) / this.k;
533
+ return new _ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
534
+ }
535
+ /** Pan by a screen-space delta. */
536
+ pan(dx, dy) {
537
+ return new _ZoomTransform(this.x + dx, this.y + dy, this.k);
538
+ }
539
+ /**
540
+ * Compute a transform that fits all nodes within the given canvas
541
+ * dimensions with the specified padding.
542
+ */
543
+ static fitBounds(nodes, canvasW, canvasH, padding = 40) {
544
+ if (nodes.length === 0) {
545
+ return _ZoomTransform.identity();
546
+ }
547
+ let minX = Infinity;
548
+ let minY = Infinity;
549
+ let maxX = -Infinity;
550
+ let maxY = -Infinity;
551
+ for (const n of nodes) {
552
+ const r = n.radius;
553
+ if (n.x - r < minX) minX = n.x - r;
554
+ if (n.y - r < minY) minY = n.y - r;
555
+ if (n.x + r > maxX) maxX = n.x + r;
556
+ if (n.y + r > maxY) maxY = n.y + r;
557
+ }
558
+ const graphW = maxX - minX;
559
+ const graphH = maxY - minY;
560
+ if (graphW === 0 && graphH === 0) {
561
+ return new _ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1);
562
+ }
563
+ const availW = canvasW - padding * 2;
564
+ const availH = canvasH - padding * 2;
565
+ const k = Math.min(availW / graphW, availH / graphH);
566
+ const cx = (minX + maxX) / 2;
567
+ const cy = (minY + maxY) / 2;
568
+ const tx = canvasW / 2 - cx * k;
569
+ const ty = canvasH / 2 - cy * k;
570
+ return new _ZoomTransform(tx, ty, k);
571
+ }
572
+ /** Identity transform (no pan, no zoom). */
573
+ static identity() {
574
+ return new _ZoomTransform(0, 0, 1);
575
+ }
576
+ };
577
+
578
+ // src/graph/interaction.ts
579
+ var ZOOM_MIN = 0.05;
580
+ var ZOOM_MAX = 15;
581
+ var ZOOM_STEP = -1e-3;
582
+ var HIT_DISTANCE = 5;
583
+ var GraphInteractionManager = class {
584
+ canvas;
585
+ spatialIndex;
586
+ callbacks;
587
+ transform = ZoomTransform.identity();
588
+ dragState = null;
589
+ panState = null;
590
+ mousedownNodeId = null;
591
+ selectedIds = /* @__PURE__ */ new Set();
592
+ // Touch state
593
+ lastTouchDist = null;
594
+ lastTouchCenter = null;
595
+ // Bound handlers for cleanup
596
+ boundWheel;
597
+ boundMouseDown;
598
+ boundMouseMove;
599
+ boundMouseUp;
600
+ boundDblClick;
601
+ boundTouchStart;
602
+ boundTouchMove;
603
+ boundTouchEnd;
604
+ boundMouseLeave;
605
+ constructor(canvas, spatialIndex, callbacks) {
606
+ this.canvas = canvas;
607
+ this.spatialIndex = spatialIndex;
608
+ this.callbacks = callbacks;
609
+ this.boundWheel = this.onWheel.bind(this);
610
+ this.boundMouseDown = this.onMouseDown.bind(this);
611
+ this.boundMouseMove = this.onMouseMove.bind(this);
612
+ this.boundMouseUp = this.onMouseUp.bind(this);
613
+ this.boundMouseLeave = this.onMouseLeave.bind(this);
614
+ this.boundDblClick = this.onDblClick.bind(this);
615
+ this.boundTouchStart = this.onTouchStart.bind(this);
616
+ this.boundTouchMove = this.onTouchMove.bind(this);
617
+ this.boundTouchEnd = this.onTouchEnd.bind(this);
618
+ canvas.addEventListener("wheel", this.boundWheel, { passive: false });
619
+ canvas.addEventListener("mousedown", this.boundMouseDown);
620
+ canvas.addEventListener("mousemove", this.boundMouseMove);
621
+ canvas.addEventListener("mouseup", this.boundMouseUp);
622
+ canvas.addEventListener("mouseleave", this.boundMouseLeave);
623
+ canvas.addEventListener("dblclick", this.boundDblClick);
624
+ canvas.addEventListener("touchstart", this.boundTouchStart, {
625
+ passive: false
626
+ });
627
+ canvas.addEventListener("touchmove", this.boundTouchMove, {
628
+ passive: false
629
+ });
630
+ canvas.addEventListener("touchend", this.boundTouchEnd);
631
+ }
632
+ setTransform(transform) {
633
+ this.transform = transform;
634
+ }
635
+ getTransform() {
636
+ return this.transform;
637
+ }
638
+ destroy() {
639
+ this.canvas.removeEventListener("wheel", this.boundWheel);
640
+ this.canvas.removeEventListener("mousedown", this.boundMouseDown);
641
+ this.canvas.removeEventListener("mousemove", this.boundMouseMove);
642
+ this.canvas.removeEventListener("mouseup", this.boundMouseUp);
643
+ this.canvas.removeEventListener("mouseleave", this.boundMouseLeave);
644
+ this.canvas.removeEventListener("dblclick", this.boundDblClick);
645
+ this.canvas.removeEventListener("touchstart", this.boundTouchStart);
646
+ this.canvas.removeEventListener("touchmove", this.boundTouchMove);
647
+ this.canvas.removeEventListener("touchend", this.boundTouchEnd);
648
+ }
649
+ // -------------------------------------------------------------------------
650
+ // Mouse handlers
651
+ // -------------------------------------------------------------------------
652
+ canvasXY(e) {
653
+ const rect = this.canvas.getBoundingClientRect();
654
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
655
+ }
656
+ hitTest(screenX, screenY) {
657
+ const graph = this.transform.screenToGraph(screenX, screenY);
658
+ const node = this.spatialIndex.findNearest(graph.x, graph.y, HIT_DISTANCE / this.transform.k);
659
+ return node?.id ?? null;
660
+ }
661
+ onWheel(e) {
662
+ e.preventDefault();
663
+ const { x, y } = this.canvasXY(e);
664
+ const factor = e.deltaY * ZOOM_STEP;
665
+ const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * (1 + factor)));
666
+ this.transform = this.transform.zoomAt(newK, x, y);
667
+ this.callbacks.onTransformChange(this.transform);
668
+ }
669
+ onMouseDown(e) {
670
+ const { x, y } = this.canvasXY(e);
671
+ const hitId = this.hitTest(x, y);
672
+ if (hitId) {
673
+ this.dragState = { nodeId: hitId, started: false };
674
+ this.mousedownNodeId = hitId;
675
+ } else {
676
+ this.panState = { startX: x, startY: y };
677
+ this.mousedownNodeId = null;
678
+ }
679
+ }
680
+ onMouseMove(e) {
681
+ const { x, y } = this.canvasXY(e);
682
+ if (this.dragState) {
683
+ const graph = this.transform.screenToGraph(x, y);
684
+ if (!this.dragState.started) {
685
+ this.dragState.started = true;
686
+ this.callbacks.onNodeDragStart(this.dragState.nodeId);
687
+ }
688
+ this.callbacks.onNodeDrag(this.dragState.nodeId, graph.x, graph.y);
689
+ return;
690
+ }
691
+ if (this.panState) {
692
+ const dx = x - this.panState.startX;
693
+ const dy = y - this.panState.startY;
694
+ this.transform = this.transform.pan(dx, dy);
695
+ this.panState = { startX: x, startY: y };
696
+ this.callbacks.onTransformChange(this.transform);
697
+ return;
698
+ }
699
+ const hitId = this.hitTest(x, y);
700
+ this.callbacks.onHoverChange(hitId);
701
+ this.canvas.style.cursor = hitId ? "pointer" : "default";
702
+ }
703
+ onMouseUp(e) {
704
+ const { x, y } = this.canvasXY(e);
705
+ if (this.dragState) {
706
+ if (this.dragState.started) {
707
+ this.callbacks.onNodeDragEnd(this.dragState.nodeId);
708
+ } else {
709
+ this.handleNodeClick(this.dragState.nodeId, e.shiftKey);
710
+ }
711
+ this.dragState = null;
712
+ return;
713
+ }
714
+ if (this.panState) {
715
+ this.panState = null;
716
+ if (!this.mousedownNodeId) {
717
+ const hitId = this.hitTest(x, y);
718
+ if (!hitId) {
719
+ this.selectedIds.clear();
720
+ this.callbacks.onSelectionChange([]);
721
+ }
722
+ }
723
+ return;
724
+ }
725
+ }
726
+ onDblClick(e) {
727
+ const { x, y } = this.canvasXY(e);
728
+ const hitId = this.hitTest(x, y);
729
+ if (hitId) {
730
+ this.callbacks.onDoubleClick(hitId);
731
+ }
732
+ }
733
+ onMouseLeave(_e) {
734
+ this.callbacks.onHoverChange(null);
735
+ this.canvas.style.cursor = "default";
736
+ if (this.panState) {
737
+ this.panState = null;
738
+ }
739
+ }
740
+ handleNodeClick(nodeId, shiftKey) {
741
+ if (shiftKey) {
742
+ if (this.selectedIds.has(nodeId)) {
743
+ this.selectedIds.delete(nodeId);
744
+ } else {
745
+ this.selectedIds.add(nodeId);
746
+ }
747
+ } else {
748
+ this.selectedIds.clear();
749
+ this.selectedIds.add(nodeId);
750
+ }
751
+ this.callbacks.onSelectionChange([...this.selectedIds]);
752
+ }
753
+ // -------------------------------------------------------------------------
754
+ // Touch handlers
755
+ // -------------------------------------------------------------------------
756
+ onTouchStart(e) {
757
+ e.preventDefault();
758
+ if (e.touches.length === 2) {
759
+ const [t0, t1] = [e.touches[0], e.touches[1]];
760
+ this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
761
+ this.lastTouchCenter = {
762
+ x: (t0.clientX + t1.clientX) / 2,
763
+ y: (t0.clientY + t1.clientY) / 2
764
+ };
765
+ } else if (e.touches.length === 1) {
766
+ const touch = e.touches[0];
767
+ const rect = this.canvas.getBoundingClientRect();
768
+ const x = touch.clientX - rect.left;
769
+ const y = touch.clientY - rect.top;
770
+ const hitId = this.hitTest(x, y);
771
+ if (hitId) {
772
+ this.mousedownNodeId = hitId;
773
+ } else {
774
+ this.panState = { startX: x, startY: y };
775
+ this.mousedownNodeId = null;
776
+ }
777
+ }
778
+ }
779
+ onTouchMove(e) {
780
+ e.preventDefault();
781
+ if (e.touches.length === 2 && this.lastTouchDist !== null) {
782
+ const [t0, t1] = [e.touches[0], e.touches[1]];
783
+ const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
784
+ const rect = this.canvas.getBoundingClientRect();
785
+ const centerX = (t0.clientX + t1.clientX) / 2 - rect.left;
786
+ const centerY = (t0.clientY + t1.clientY) / 2 - rect.top;
787
+ const scale = newDist / this.lastTouchDist;
788
+ const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * scale));
789
+ this.transform = this.transform.zoomAt(newK, centerX, centerY);
790
+ if (this.lastTouchCenter) {
791
+ const dx = centerX - (this.lastTouchCenter.x - rect.left);
792
+ const dy = centerY - (this.lastTouchCenter.y - rect.top);
793
+ this.transform = this.transform.pan(dx, dy);
794
+ }
795
+ this.lastTouchDist = newDist;
796
+ this.lastTouchCenter = {
797
+ x: (t0.clientX + t1.clientX) / 2,
798
+ y: (t0.clientY + t1.clientY) / 2
799
+ };
800
+ this.callbacks.onTransformChange(this.transform);
801
+ } else if (e.touches.length === 1 && this.panState) {
802
+ const touch = e.touches[0];
803
+ const rect = this.canvas.getBoundingClientRect();
804
+ const x = touch.clientX - rect.left;
805
+ const y = touch.clientY - rect.top;
806
+ const dx = x - this.panState.startX;
807
+ const dy = y - this.panState.startY;
808
+ this.transform = this.transform.pan(dx, dy);
809
+ this.panState = { startX: x, startY: y };
810
+ this.callbacks.onTransformChange(this.transform);
811
+ }
812
+ }
813
+ onTouchEnd(e) {
814
+ if (e.touches.length === 0) {
815
+ if (this.mousedownNodeId && !this.panState) {
816
+ this.handleNodeClick(this.mousedownNodeId, false);
817
+ } else if (!this.mousedownNodeId && this.panState) {
818
+ this.selectedIds.clear();
819
+ this.callbacks.onSelectionChange([]);
820
+ }
821
+ this.panState = null;
822
+ this.mousedownNodeId = null;
823
+ this.lastTouchDist = null;
824
+ this.lastTouchCenter = null;
825
+ }
826
+ }
827
+ };
828
+
829
+ // src/graph/keyboard.ts
830
+ function attachGraphKeyboardNav(options) {
831
+ const {
832
+ canvas,
833
+ getNodes,
834
+ getSelectedIds,
835
+ getAdjacency,
836
+ onSelect,
837
+ onDeselect,
838
+ onZoom,
839
+ onFitAll,
840
+ onFocusSearch
841
+ } = options;
842
+ let focusedNodeId = null;
843
+ if (!canvas.hasAttribute("tabindex")) {
844
+ canvas.setAttribute("tabindex", "0");
845
+ }
846
+ function findNodeById(id) {
847
+ return getNodes().find((n) => n.id === id);
848
+ }
849
+ function pickDirectionalNeighbor(fromNode, neighborIds, direction) {
850
+ const nodes = getNodes();
851
+ const candidates = nodes.filter((n) => neighborIds.has(n.id));
852
+ if (candidates.length === 0) return null;
853
+ let best = null;
854
+ let bestScore = -Infinity;
855
+ for (const c of candidates) {
856
+ const dx = c.x - fromNode.x;
857
+ const dy = c.y - fromNode.y;
858
+ let score;
859
+ switch (direction) {
860
+ case "right":
861
+ score = dx - Math.abs(dy) * 0.5;
862
+ break;
863
+ case "left":
864
+ score = -dx - Math.abs(dy) * 0.5;
865
+ break;
866
+ case "down":
867
+ score = dy - Math.abs(dx) * 0.5;
868
+ break;
869
+ case "up":
870
+ score = -dy - Math.abs(dx) * 0.5;
871
+ break;
872
+ }
873
+ if (score > bestScore) {
874
+ bestScore = score;
875
+ best = c;
876
+ }
877
+ }
878
+ return best?.id ?? null;
879
+ }
880
+ function onKeyDown(e) {
881
+ switch (e.key) {
882
+ case "Tab": {
883
+ const selected = getSelectedIds();
884
+ const nodes = getNodes();
885
+ if (nodes.length === 0) return;
886
+ if (selected.length > 0) {
887
+ focusedNodeId = selected[0];
888
+ } else if (!focusedNodeId || !findNodeById(focusedNodeId)) {
889
+ focusedNodeId = nodes[0].id;
890
+ }
891
+ e.preventDefault();
892
+ break;
893
+ }
894
+ case "ArrowUp":
895
+ case "ArrowDown":
896
+ case "ArrowLeft":
897
+ case "ArrowRight": {
898
+ if (!focusedNodeId) return;
899
+ e.preventDefault();
900
+ const focusedNode = findNodeById(focusedNodeId);
901
+ if (!focusedNode) return;
902
+ const adjacency = getAdjacency();
903
+ const neighbors = adjacency.get(focusedNodeId);
904
+ if (!neighbors || neighbors.size === 0) return;
905
+ const dirMap = {
906
+ ArrowUp: "up",
907
+ ArrowDown: "down",
908
+ ArrowLeft: "left",
909
+ ArrowRight: "right"
910
+ };
911
+ const nextId = pickDirectionalNeighbor(focusedNode, neighbors, dirMap[e.key]);
912
+ if (nextId) {
913
+ focusedNodeId = nextId;
914
+ onSelect(nextId);
915
+ }
916
+ break;
917
+ }
918
+ case "Enter": {
919
+ if (focusedNodeId) {
920
+ e.preventDefault();
921
+ const selected = getSelectedIds();
922
+ if (selected.includes(focusedNodeId)) {
923
+ onDeselect();
924
+ } else {
925
+ onSelect(focusedNodeId);
926
+ }
927
+ }
928
+ break;
929
+ }
930
+ case "Escape": {
931
+ e.preventDefault();
932
+ focusedNodeId = null;
933
+ onDeselect();
934
+ break;
935
+ }
936
+ case "+":
937
+ case "=": {
938
+ e.preventDefault();
939
+ onZoom("in");
940
+ break;
941
+ }
942
+ case "-":
943
+ case "_": {
944
+ e.preventDefault();
945
+ onZoom("out");
946
+ break;
947
+ }
948
+ case "Home": {
949
+ e.preventDefault();
950
+ onFitAll();
951
+ break;
952
+ }
953
+ case "/": {
954
+ if (onFocusSearch) {
955
+ e.preventDefault();
956
+ onFocusSearch();
957
+ }
958
+ break;
959
+ }
960
+ }
961
+ }
962
+ canvas.addEventListener("keydown", onKeyDown);
963
+ return () => {
964
+ canvas.removeEventListener("keydown", onKeyDown);
965
+ };
966
+ }
967
+
968
+ // src/graph/search.ts
969
+ var GraphSearchManager = class {
970
+ matchedIds = null;
971
+ /**
972
+ * Search for nodes matching the query string.
973
+ * Returns a Set of matching node ids, or an empty set if nothing matches.
974
+ */
975
+ search(query, nodes) {
976
+ const q = query.toLowerCase().trim();
977
+ if (q === "") {
978
+ this.matchedIds = null;
979
+ return /* @__PURE__ */ new Set();
980
+ }
981
+ const matches = /* @__PURE__ */ new Set();
982
+ for (const node of nodes) {
983
+ const label = (node.label ?? "").toLowerCase();
984
+ const id = node.id.toLowerCase();
985
+ if (label.includes(q) || id.includes(q)) {
986
+ matches.add(node.id);
987
+ }
988
+ }
989
+ this.matchedIds = matches;
990
+ return matches;
991
+ }
992
+ /**
993
+ * Clear the current search.
994
+ * Returns null to indicate no active search.
995
+ */
996
+ clearSearch() {
997
+ this.matchedIds = null;
998
+ return null;
999
+ }
1000
+ /** Get the current set of matched ids, or null if no search is active. */
1001
+ getMatches() {
1002
+ return this.matchedIds;
1003
+ }
1004
+ };
1005
+
1006
+ // src/graph/simulation.ts
1007
+ import {
1008
+ forceCenter,
1009
+ forceCollide,
1010
+ forceLink,
1011
+ forceManyBody,
1012
+ forceSimulation,
1013
+ forceX,
1014
+ forceY
1015
+ } from "d3-force";
1016
+ var SYNC_THRESHOLD = 200;
1017
+ var SYNC_MAX_TICKS = 300;
1018
+ function forceCluster(nodes, strength) {
1019
+ return (alpha) => {
1020
+ const cx = /* @__PURE__ */ new Map();
1021
+ const cy = /* @__PURE__ */ new Map();
1022
+ const count = /* @__PURE__ */ new Map();
1023
+ for (const node of nodes) {
1024
+ if (!node.community) continue;
1025
+ const c = node.community;
1026
+ cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
1027
+ cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
1028
+ count.set(c, (count.get(c) ?? 0) + 1);
1029
+ }
1030
+ for (const [c, n] of count) {
1031
+ cx.set(c, cx.get(c) / n);
1032
+ cy.set(c, cy.get(c) / n);
1033
+ }
1034
+ const k = strength * alpha;
1035
+ for (const node of nodes) {
1036
+ if (!node.community) continue;
1037
+ const targetX = cx.get(node.community);
1038
+ const targetY = cy.get(node.community);
1039
+ node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
1040
+ node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
1041
+ }
1042
+ };
1043
+ }
1044
+ var SimulationManager = class _SimulationManager {
1045
+ worker = null;
1046
+ syncSim = null;
1047
+ syncNodes = [];
1048
+ syncNodeMap = /* @__PURE__ */ new Map();
1049
+ tickCb = null;
1050
+ settledCb = null;
1051
+ destroyed = false;
1052
+ // Stored for worker->sync fallback
1053
+ initNodes = [];
1054
+ initEdges = [];
1055
+ initConfig = null;
1056
+ constructor() {
1057
+ }
1058
+ /**
1059
+ * Create a SimulationManager. Uses Web Worker for large graphs,
1060
+ * synchronous fallback for small graphs or when Worker unavailable.
1061
+ */
1062
+ static create(nodes, edges, config) {
1063
+ const mgr = new _SimulationManager();
1064
+ const useWorker = typeof Worker !== "undefined" && nodes.length >= SYNC_THRESHOLD;
1065
+ if (useWorker) {
1066
+ mgr.initWorker(nodes, edges, config);
1067
+ } else {
1068
+ mgr.initSync(nodes, edges, config);
1069
+ }
1070
+ return mgr;
1071
+ }
1072
+ /** Register a callback for position updates. */
1073
+ onTick(cb) {
1074
+ this.tickCb = cb;
1075
+ }
1076
+ /** Register a callback for when the simulation has settled. */
1077
+ onSettled(cb) {
1078
+ this.settledCb = cb;
1079
+ }
1080
+ /** Reheat the simulation. */
1081
+ reheat(alpha) {
1082
+ if (this.destroyed) return;
1083
+ if (this.worker) {
1084
+ this.worker.postMessage({ type: "reheat", alpha });
1085
+ } else if (this.syncSim) {
1086
+ this.syncSim.alpha(alpha ?? 0.3).restart();
1087
+ this.runSyncTicks();
1088
+ }
1089
+ }
1090
+ /** Pin a node to fixed x/y coordinates. */
1091
+ pinNode(id, x, y) {
1092
+ if (this.destroyed) return;
1093
+ if (this.worker) {
1094
+ this.worker.postMessage({ type: "pin", nodeId: id, x, y });
1095
+ } else {
1096
+ const node = this.syncNodeMap.get(id);
1097
+ if (node) {
1098
+ node.fx = x;
1099
+ node.fy = y;
1100
+ }
1101
+ }
1102
+ }
1103
+ /** Unpin a node (free it from fixed position). */
1104
+ unpinNode(id) {
1105
+ if (this.destroyed) return;
1106
+ if (this.worker) {
1107
+ this.worker.postMessage({ type: "unpin", nodeId: id });
1108
+ } else {
1109
+ const node = this.syncNodeMap.get(id);
1110
+ if (node) {
1111
+ node.fx = null;
1112
+ node.fy = null;
1113
+ }
1114
+ }
1115
+ }
1116
+ /** Drag a node (pins it and reheats slightly). */
1117
+ dragNode(id, x, y) {
1118
+ if (this.destroyed) return;
1119
+ if (this.worker) {
1120
+ this.worker.postMessage({ type: "drag", nodeId: id, x, y });
1121
+ } else {
1122
+ const node = this.syncNodeMap.get(id);
1123
+ if (node) {
1124
+ node.fx = x;
1125
+ node.fy = y;
1126
+ }
1127
+ if (this.syncSim && this.syncSim.alpha() < 0.1) {
1128
+ this.syncSim.alpha(0.1).restart();
1129
+ this.runSyncTicks();
1130
+ }
1131
+ }
1132
+ }
1133
+ /** Tear down the simulation and release resources. */
1134
+ destroy() {
1135
+ this.destroyed = true;
1136
+ if (this.worker) {
1137
+ this.worker.postMessage({ type: "stop" });
1138
+ this.worker.terminate();
1139
+ this.worker = null;
1140
+ }
1141
+ if (this.syncSim) {
1142
+ this.syncSim.stop();
1143
+ this.syncSim = null;
1144
+ }
1145
+ this.tickCb = null;
1146
+ this.settledCb = null;
1147
+ }
1148
+ // -------------------------------------------------------------------------
1149
+ // Worker path
1150
+ // -------------------------------------------------------------------------
1151
+ initWorker(nodes, edges, config) {
1152
+ this.initNodes = nodes;
1153
+ this.initEdges = edges;
1154
+ this.initConfig = config;
1155
+ try {
1156
+ const workerUrl2 = new URL("./simulation-worker.ts", import.meta.url);
1157
+ this.worker = new Worker(workerUrl2, { type: "module" });
1158
+ } catch {
1159
+ console.warn("[SimulationManager] Worker creation failed, using sync fallback");
1160
+ this.initSync(nodes, edges, config);
1161
+ return;
1162
+ }
1163
+ this.worker.onmessage = (event) => {
1164
+ if (this.destroyed) return;
1165
+ const msg = event.data;
1166
+ switch (msg.type) {
1167
+ case "positions":
1168
+ this.tickCb?.(msg.nodes, msg.alpha);
1169
+ break;
1170
+ case "settled":
1171
+ this.settledCb?.();
1172
+ break;
1173
+ case "error":
1174
+ console.error("[SimulationManager] Worker error:", msg.message);
1175
+ break;
1176
+ }
1177
+ };
1178
+ this.worker.onerror = () => {
1179
+ if (this.destroyed) return;
1180
+ console.warn("[SimulationManager] Worker failed to load, falling back to sync");
1181
+ this.worker?.terminate();
1182
+ this.worker = null;
1183
+ this.initSync(this.initNodes, this.initEdges, this.initConfig);
1184
+ };
1185
+ this.worker.postMessage({ type: "init", nodes, edges, config });
1186
+ }
1187
+ // -------------------------------------------------------------------------
1188
+ // Synchronous fallback
1189
+ // -------------------------------------------------------------------------
1190
+ initSync(nodes, edges, config) {
1191
+ this.syncNodes = nodes.map((n) => ({
1192
+ id: n.id,
1193
+ x: n.x,
1194
+ y: n.y,
1195
+ radius: n.radius,
1196
+ community: n.community
1197
+ }));
1198
+ this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
1199
+ this.syncSim = forceSimulation(this.syncNodes).force(
1200
+ "link",
1201
+ forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance)
1202
+ ).force("charge", forceManyBody().strength(config.chargeStrength)).force("center", forceCenter(0, 0)).force(
1203
+ "collide",
1204
+ forceCollide().radius((d) => d.radius + 1)
1205
+ ).force("gravityX", forceX(0).strength(0.05)).force("gravityY", forceY(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
1206
+ if (config.clustering) {
1207
+ const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
1208
+ this.syncSim.force("cluster", clusterFn);
1209
+ }
1210
+ this.runSyncTicks(true);
1211
+ }
1212
+ /**
1213
+ * Run ticks synchronously and fire callbacks.
1214
+ * @param deferred - When true, deliver results via microtask. Used for the
1215
+ * initial run where callbacks haven't been registered yet. Subsequent
1216
+ * calls (reheat, drag) fire synchronously since callbacks are already set.
1217
+ */
1218
+ runSyncTicks(deferred = false) {
1219
+ if (!this.syncSim || this.destroyed) return;
1220
+ const sim = this.syncSim;
1221
+ for (let i = 0; i < SYNC_MAX_TICKS; i++) {
1222
+ sim.tick();
1223
+ if (sim.alpha() < 1e-3) break;
1224
+ }
1225
+ const positions = this.syncNodes.map((n) => ({
1226
+ id: n.id,
1227
+ x: n.x ?? 0,
1228
+ y: n.y ?? 0
1229
+ }));
1230
+ const alpha = sim.alpha();
1231
+ const settled = alpha < 1e-3;
1232
+ const deliver = () => {
1233
+ if (this.destroyed) return;
1234
+ this.tickCb?.(positions, alpha);
1235
+ if (settled) {
1236
+ this.settledCb?.();
1237
+ }
1238
+ };
1239
+ if (deferred) {
1240
+ queueMicrotask(deliver);
1241
+ } else {
1242
+ deliver();
1243
+ }
1244
+ }
1245
+ };
1246
+
1247
+ // src/graph/spatial-index.ts
1248
+ import { quadtree } from "d3-quadtree";
1249
+ var SpatialIndex = class {
1250
+ tree = null;
1251
+ nodes = [];
1252
+ maxRadius = 0;
1253
+ generation = 0;
1254
+ /** Rebuild the quadtree from the current set of positioned nodes. */
1255
+ rebuild(nodes) {
1256
+ this.nodes = nodes;
1257
+ this.maxRadius = 0;
1258
+ for (const n of nodes) {
1259
+ if (n.radius > this.maxRadius) this.maxRadius = n.radius;
1260
+ }
1261
+ this.tree = quadtree().x((d) => d.x).y((d) => d.y).addAll(nodes);
1262
+ this.generation++;
1263
+ }
1264
+ /** Current generation counter. Increments on each rebuild. */
1265
+ getGeneration() {
1266
+ return this.generation;
1267
+ }
1268
+ /**
1269
+ * Find the nearest node to (x, y) within maxDistance.
1270
+ * Accounts for node radius: a hit occurs if the point is inside
1271
+ * the node circle (distance to center < node.radius), or if the
1272
+ * edge-to-edge distance is within maxDistance.
1273
+ */
1274
+ findNearest(x, y, maxDistance = Infinity) {
1275
+ if (!this.tree || this.nodes.length === 0) return null;
1276
+ const searchRadius = maxDistance + this.maxRadius;
1277
+ let best = null;
1278
+ let bestEffectiveDist = maxDistance + this.maxRadius + 1;
1279
+ this.tree.visit((node, x0, y0, x1, y1) => {
1280
+ const closestX = Math.max(x0, Math.min(x, x1));
1281
+ const closestY = Math.max(y0, Math.min(y, y1));
1282
+ const quadDist = Math.hypot(closestX - x, closestY - y);
1283
+ if (quadDist > searchRadius) return true;
1284
+ if (!node.length) {
1285
+ let current = node;
1286
+ do {
1287
+ const d = current.data;
1288
+ if (d) {
1289
+ const dist = Math.hypot(d.x - x, d.y - y);
1290
+ const effectiveDist = Math.max(0, dist - d.radius);
1291
+ if (effectiveDist <= maxDistance && effectiveDist < bestEffectiveDist) {
1292
+ bestEffectiveDist = effectiveDist;
1293
+ best = d;
1294
+ }
1295
+ }
1296
+ } while (current = current.next);
1297
+ }
1298
+ return false;
1299
+ });
1300
+ return best;
1301
+ }
1302
+ /** Find all nodes whose centers fall within the given rectangle. */
1303
+ findInRect(x1, y1, x2, y2) {
1304
+ if (!this.tree) return [];
1305
+ const minX = Math.min(x1, x2);
1306
+ const minY = Math.min(y1, y2);
1307
+ const maxX = Math.max(x1, x2);
1308
+ const maxY = Math.max(y1, y2);
1309
+ const results = [];
1310
+ this.tree.visit((node, qx0, qy0, qx1, qy1) => {
1311
+ if (qx0 > maxX || qx1 < minX || qy0 > maxY || qy1 < minY) {
1312
+ return true;
1313
+ }
1314
+ if (!node.length) {
1315
+ let current = node;
1316
+ do {
1317
+ const d = current.data;
1318
+ if (d && d.x >= minX && d.x <= maxX && d.y >= minY && d.y <= maxY) {
1319
+ results.push(d);
1320
+ }
1321
+ } while (current = current.next);
1322
+ }
1323
+ return false;
1324
+ });
1325
+ return results;
1326
+ }
1327
+ };
1328
+
1329
+ // src/resize-observer.ts
1330
+ var DEBOUNCE_MS = 16;
1331
+ function observeResize(container, callback) {
1332
+ let timeoutId = null;
1333
+ const observer = new ResizeObserver((entries) => {
1334
+ if (timeoutId !== null) {
1335
+ clearTimeout(timeoutId);
1336
+ }
1337
+ timeoutId = setTimeout(() => {
1338
+ for (const entry of entries) {
1339
+ const { width, height } = entry.contentRect;
1340
+ callback(width, height);
1341
+ }
1342
+ timeoutId = null;
1343
+ }, DEBOUNCE_MS);
1344
+ });
1345
+ observer.observe(container);
1346
+ return () => {
1347
+ if (timeoutId !== null) {
1348
+ clearTimeout(timeoutId);
1349
+ }
1350
+ observer.disconnect();
1351
+ };
1352
+ }
1353
+
1354
+ // src/tooltip.ts
1355
+ var TOOLTIP_OFFSET = 12;
1356
+ function createTooltipManager(container) {
1357
+ const tooltip = document.createElement("div");
1358
+ tooltip.className = "viz-tooltip";
1359
+ tooltip.setAttribute("role", "tooltip");
1360
+ container.style.position = container.style.position || "relative";
1361
+ container.appendChild(tooltip);
1362
+ const handleDocumentTouch = (e) => {
1363
+ if (!container.contains(e.target)) {
1364
+ hide();
1365
+ }
1366
+ };
1367
+ document.addEventListener("touchstart", handleDocumentTouch);
1368
+ function show(content, x, y) {
1369
+ let html = "";
1370
+ if (content.title) {
1371
+ const titleColor = content.fields.find((f) => f.color)?.color;
1372
+ html += '<div class="viz-tooltip-header">';
1373
+ if (titleColor) {
1374
+ html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
1375
+ }
1376
+ html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
1377
+ html += "</div>";
1378
+ }
1379
+ if (content.fields.length > 0) {
1380
+ html += '<div class="viz-tooltip-body">';
1381
+ for (const field of content.fields) {
1382
+ html += '<div class="viz-tooltip-row">';
1383
+ html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
1384
+ html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
1385
+ html += "</div>";
1386
+ }
1387
+ html += "</div>";
1388
+ }
1389
+ tooltip.innerHTML = html;
1390
+ tooltip.style.display = "block";
1391
+ const containerRect = container.getBoundingClientRect();
1392
+ const tooltipRect = tooltip.getBoundingClientRect();
1393
+ let left = x + TOOLTIP_OFFSET;
1394
+ let top = y + TOOLTIP_OFFSET;
1395
+ if (left + tooltipRect.width > containerRect.width) {
1396
+ left = x - tooltipRect.width - TOOLTIP_OFFSET;
1397
+ }
1398
+ if (top + tooltipRect.height > containerRect.height) {
1399
+ top = y - tooltipRect.height - TOOLTIP_OFFSET;
1400
+ }
1401
+ left = Math.max(0, Math.min(left, containerRect.width - tooltipRect.width));
1402
+ top = Math.max(0, Math.min(top, containerRect.height - tooltipRect.height));
1403
+ tooltip.style.left = `${left}px`;
1404
+ tooltip.style.top = `${top}px`;
1405
+ }
1406
+ function hide() {
1407
+ tooltip.style.display = "none";
1408
+ }
1409
+ function destroy() {
1410
+ document.removeEventListener("touchstart", handleDocumentTouch);
1411
+ if (tooltip.parentNode) {
1412
+ tooltip.parentNode.removeChild(tooltip);
1413
+ }
1414
+ }
1415
+ return { show, hide, destroy };
1416
+ }
1417
+ function esc(str) {
1418
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1419
+ }
1420
+
1421
+ // src/graph-mount.ts
1422
+ function resolveDarkMode(mode) {
1423
+ if (mode === "force") return true;
1424
+ if (mode === "off" || mode === void 0) return false;
1425
+ if (typeof window !== "undefined" && window.matchMedia) {
1426
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
1427
+ }
1428
+ return false;
1429
+ }
1430
+ function createGraph(container, spec, options) {
1431
+ let currentSpec = spec;
1432
+ let compilation;
1433
+ let destroyed = false;
1434
+ let wrapper = null;
1435
+ let canvas = null;
1436
+ let chromeEl = null;
1437
+ let legendEl = null;
1438
+ let renderer = null;
1439
+ let simulation = null;
1440
+ const spatialIndex = new SpatialIndex();
1441
+ let interactionManager = null;
1442
+ const searchManager = new GraphSearchManager();
1443
+ let tooltipManager = null;
1444
+ let cleanupKeyboard = null;
1445
+ let disconnectResize = null;
1446
+ let positionedNodes = [];
1447
+ let positionedEdges = [];
1448
+ let adjacencyMap = /* @__PURE__ */ new Map();
1449
+ let hoveredNodeId = null;
1450
+ let selectedNodeIds = /* @__PURE__ */ new Set();
1451
+ let animFrameId = null;
1452
+ let needsRender = false;
1453
+ let isGesturing = false;
1454
+ let gestureTimeout = null;
1455
+ function markGesture() {
1456
+ isGesturing = true;
1457
+ if (gestureTimeout !== null) clearTimeout(gestureTimeout);
1458
+ gestureTimeout = setTimeout(() => {
1459
+ isGesturing = false;
1460
+ gestureTimeout = null;
1461
+ needsRender = true;
1462
+ scheduleRender();
1463
+ }, 150);
1464
+ }
1465
+ function getContainerDimensions() {
1466
+ const rect = container.getBoundingClientRect();
1467
+ return {
1468
+ width: Math.max(rect.width || 600, 100),
1469
+ height: Math.max(rect.height || 400, 100)
1470
+ };
1471
+ }
1472
+ function compile() {
1473
+ const { width, height } = getContainerDimensions();
1474
+ const darkMode = resolveDarkMode(options?.darkMode);
1475
+ const compileOpts = {
1476
+ width,
1477
+ height,
1478
+ theme: options?.theme,
1479
+ darkMode
1480
+ };
1481
+ return compileGraph(currentSpec, compileOpts);
1482
+ }
1483
+ function buildAdjacencyMap(edges) {
1484
+ const map = /* @__PURE__ */ new Map();
1485
+ for (const edge of edges) {
1486
+ if (!map.has(edge.source)) map.set(edge.source, /* @__PURE__ */ new Set());
1487
+ if (!map.has(edge.target)) map.set(edge.target, /* @__PURE__ */ new Set());
1488
+ map.get(edge.source).add(edge.target);
1489
+ map.get(edge.target).add(edge.source);
1490
+ }
1491
+ return map;
1492
+ }
1493
+ function toSimNodes(nodes) {
1494
+ return nodes.map((n) => ({
1495
+ id: n.id,
1496
+ radius: n.radius,
1497
+ community: n.community
1498
+ }));
1499
+ }
1500
+ function toSimEdges(edges) {
1501
+ return edges.map((e) => ({
1502
+ source: e.source,
1503
+ target: e.target
1504
+ }));
1505
+ }
1506
+ function nodeDataById(nodeId) {
1507
+ const node = compilation.nodes.find((n) => n.id === nodeId);
1508
+ return node?.data ?? {};
1509
+ }
1510
+ function createDOM() {
1511
+ const { width, height } = getContainerDimensions();
1512
+ const isDark = resolveDarkMode(options?.darkMode);
1513
+ wrapper = document.createElement("div");
1514
+ wrapper.className = "viz-graph-wrapper";
1515
+ if (isDark) {
1516
+ container.classList.add("viz-dark");
1517
+ } else {
1518
+ container.classList.remove("viz-dark");
1519
+ }
1520
+ chromeEl = document.createElement("div");
1521
+ chromeEl.className = "viz-graph-chrome";
1522
+ renderChrome2();
1523
+ wrapper.appendChild(chromeEl);
1524
+ canvas = document.createElement("canvas");
1525
+ canvas.className = "viz-graph-canvas";
1526
+ canvas.setAttribute("role", "img");
1527
+ if (compilation.a11y?.altText) {
1528
+ canvas.setAttribute("aria-label", compilation.a11y.altText);
1529
+ }
1530
+ wrapper.appendChild(canvas);
1531
+ legendEl = document.createElement("div");
1532
+ legendEl.className = "viz-graph-legend";
1533
+ renderLegend2();
1534
+ wrapper.appendChild(legendEl);
1535
+ container.appendChild(wrapper);
1536
+ const chromeHeight = chromeEl.getBoundingClientRect().height || 0;
1537
+ const canvasHeight = Math.max(height - chromeHeight, 200);
1538
+ renderer = new GraphCanvasRenderer(canvas);
1539
+ renderer.resize(width, canvasHeight);
1540
+ }
1541
+ function renderChrome2() {
1542
+ if (!chromeEl) return;
1543
+ let html = "";
1544
+ if (compilation.chrome.title) {
1545
+ html += `<h2 class="viz-title">${escapeHtml(compilation.chrome.title.text)}</h2>`;
1546
+ }
1547
+ if (compilation.chrome.subtitle) {
1548
+ html += `<p class="viz-subtitle">${escapeHtml(compilation.chrome.subtitle.text)}</p>`;
1549
+ }
1550
+ chromeEl.innerHTML = html;
1551
+ if (!html) {
1552
+ chromeEl.style.display = "none";
1553
+ } else {
1554
+ chromeEl.style.display = "";
1555
+ }
1556
+ }
1557
+ function renderLegend2() {
1558
+ if (!legendEl) return;
1559
+ const entries = compilation.legend.entries;
1560
+ if (entries.length === 0) {
1561
+ legendEl.style.display = "none";
1562
+ return;
1563
+ }
1564
+ legendEl.style.display = "";
1565
+ let html = "";
1566
+ for (const entry of entries) {
1567
+ html += '<div class="viz-graph-legend-item">';
1568
+ html += `<span class="viz-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span>`;
1569
+ html += `<span>${escapeHtml(entry.label)}</span>`;
1570
+ html += "</div>";
1571
+ }
1572
+ legendEl.innerHTML = html;
1573
+ }
1574
+ function initSimulation() {
1575
+ const simNodes = toSimNodes(compilation.nodes);
1576
+ const simEdges = toSimEdges(compilation.edges);
1577
+ const config = compilation.simulationConfig;
1578
+ simulation = SimulationManager.create(simNodes, simEdges, {
1579
+ chargeStrength: config.chargeStrength,
1580
+ linkDistance: config.linkDistance,
1581
+ clustering: config.clustering,
1582
+ alphaDecay: config.alphaDecay,
1583
+ velocityDecay: config.velocityDecay,
1584
+ collisionRadius: config.collisionRadius
1585
+ });
1586
+ simulation.onTick((positions, _alpha) => {
1587
+ if (destroyed) return;
1588
+ const posMap = /* @__PURE__ */ new Map();
1589
+ for (const p of positions) {
1590
+ posMap.set(p.id, { x: p.x, y: p.y });
1591
+ }
1592
+ positionedNodes = compilation.nodes.map((node) => {
1593
+ const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
1594
+ return { ...node, x: pos.x, y: pos.y };
1595
+ });
1596
+ positionedEdges = compilation.edges.map((edge) => {
1597
+ const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
1598
+ const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
1599
+ return {
1600
+ ...edge,
1601
+ sourceX: src.x,
1602
+ sourceY: src.y,
1603
+ targetX: tgt.x,
1604
+ targetY: tgt.y
1605
+ };
1606
+ });
1607
+ spatialIndex.rebuild(positionedNodes);
1608
+ needsRender = true;
1609
+ scheduleRender();
1610
+ });
1611
+ simulation.onSettled(() => {
1612
+ if (canvas && positionedNodes.length > 0 && interactionManager) {
1613
+ const { width: cw, height: ch } = getCanvasDimensions();
1614
+ const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
1615
+ interactionManager.setTransform(fitTransform);
1616
+ needsRender = true;
1617
+ scheduleRender();
1618
+ }
1619
+ });
1620
+ }
1621
+ function getCanvasDimensions() {
1622
+ if (!canvas) return { width: 600, height: 400 };
1623
+ const rect = canvas.getBoundingClientRect();
1624
+ return {
1625
+ width: Math.max(rect.width || 600, 100),
1626
+ height: Math.max(rect.height || 400, 100)
1627
+ };
1628
+ }
1629
+ function scheduleRender() {
1630
+ if (animFrameId !== null || destroyed) return;
1631
+ animFrameId = requestAnimationFrame(renderFrame);
1632
+ }
1633
+ function renderFrame() {
1634
+ animFrameId = null;
1635
+ if (destroyed || !renderer || !interactionManager) return;
1636
+ if (needsRender) {
1637
+ needsRender = false;
1638
+ const transform = interactionManager.getTransform();
1639
+ const state = {
1640
+ nodes: positionedNodes,
1641
+ edges: positionedEdges,
1642
+ transform: { x: transform.x, y: transform.y, k: transform.k },
1643
+ hoveredNodeId,
1644
+ selectedNodeIds,
1645
+ adjacencyMap,
1646
+ theme: compilation.theme,
1647
+ searchMatches: searchManager.getMatches(),
1648
+ isGesturing
1649
+ };
1650
+ renderer.render(state);
1651
+ }
1652
+ }
1653
+ function initInteraction() {
1654
+ if (!canvas) return;
1655
+ tooltipManager = createTooltipManager(wrapper);
1656
+ interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
1657
+ onTransformChange(_transform) {
1658
+ markGesture();
1659
+ needsRender = true;
1660
+ scheduleRender();
1661
+ },
1662
+ onHoverChange(nodeId) {
1663
+ hoveredNodeId = nodeId;
1664
+ needsRender = true;
1665
+ scheduleRender();
1666
+ if (nodeId && tooltipManager) {
1667
+ const content = compilation.tooltipDescriptors.get(nodeId);
1668
+ if (content) {
1669
+ const node = positionedNodes.find((n) => n.id === nodeId);
1670
+ if (node && interactionManager) {
1671
+ const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
1672
+ tooltipManager.show(content, screen.x, screen.y);
1673
+ }
1674
+ }
1675
+ } else {
1676
+ tooltipManager?.hide();
1677
+ }
1678
+ },
1679
+ onSelectionChange(nodeIds) {
1680
+ selectedNodeIds = new Set(nodeIds);
1681
+ needsRender = true;
1682
+ scheduleRender();
1683
+ options?.onSelectionChange?.(nodeIds);
1684
+ if (nodeIds.length > 0) {
1685
+ const lastId = nodeIds[nodeIds.length - 1];
1686
+ options?.onNodeClick?.(nodeDataById(lastId));
1687
+ }
1688
+ },
1689
+ onNodeDragStart(nodeId) {
1690
+ const node = positionedNodes.find((n) => n.id === nodeId);
1691
+ const x = node?.x ?? 0;
1692
+ const y = node?.y ?? 0;
1693
+ simulation?.pinNode(nodeId, x, y);
1694
+ canvas?.classList.add("viz-graph-canvas--dragging");
1695
+ },
1696
+ onNodeDrag(nodeId, x, y) {
1697
+ simulation?.dragNode(nodeId, x, y);
1698
+ },
1699
+ onNodeDragEnd(nodeId) {
1700
+ simulation?.unpinNode(nodeId);
1701
+ canvas?.classList.remove("viz-graph-canvas--dragging");
1702
+ },
1703
+ onDoubleClick(nodeId) {
1704
+ options?.onNodeDoubleClick?.(nodeDataById(nodeId));
1705
+ }
1706
+ });
1707
+ cleanupKeyboard = attachGraphKeyboardNav({
1708
+ canvas,
1709
+ getNodes: () => positionedNodes,
1710
+ getSelectedIds: () => [...selectedNodeIds],
1711
+ getAdjacency: () => adjacencyMap,
1712
+ onSelect(nodeId) {
1713
+ selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
1714
+ needsRender = true;
1715
+ scheduleRender();
1716
+ options?.onNodeClick?.(nodeDataById(nodeId));
1717
+ options?.onSelectionChange?.([nodeId]);
1718
+ },
1719
+ onDeselect() {
1720
+ selectedNodeIds.clear();
1721
+ needsRender = true;
1722
+ scheduleRender();
1723
+ options?.onSelectionChange?.([]);
1724
+ },
1725
+ onZoom(direction) {
1726
+ if (!interactionManager || !canvas) return;
1727
+ const t = interactionManager.getTransform();
1728
+ const { width: cw, height: ch } = getCanvasDimensions();
1729
+ const factor = direction === "in" ? 1.2 : 0.8;
1730
+ const newK = t.k * factor;
1731
+ const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
1732
+ interactionManager.setTransform(newTransform);
1733
+ needsRender = true;
1734
+ scheduleRender();
1735
+ },
1736
+ onFitAll() {
1737
+ zoomToFit();
1738
+ }
1739
+ });
1740
+ }
1741
+ function search(query) {
1742
+ if (destroyed) return;
1743
+ searchManager.search(query, positionedNodes);
1744
+ needsRender = true;
1745
+ scheduleRender();
1746
+ }
1747
+ function clearSearch() {
1748
+ if (destroyed) return;
1749
+ searchManager.clearSearch();
1750
+ needsRender = true;
1751
+ scheduleRender();
1752
+ }
1753
+ function zoomToFit() {
1754
+ if (destroyed || !interactionManager || positionedNodes.length === 0) return;
1755
+ const { width: cw, height: ch } = getCanvasDimensions();
1756
+ const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
1757
+ interactionManager.setTransform(fitTransform);
1758
+ needsRender = true;
1759
+ scheduleRender();
1760
+ }
1761
+ function zoomToNode(nodeId) {
1762
+ if (destroyed || !interactionManager || !canvas) return;
1763
+ const node = positionedNodes.find((n) => n.id === nodeId);
1764
+ if (!node) return;
1765
+ const { width: cw, height: ch } = getCanvasDimensions();
1766
+ const k = 2;
1767
+ const tx = cw / 2 - node.x * k;
1768
+ const ty = ch / 2 - node.y * k;
1769
+ const newTransform = new ZoomTransform(tx, ty, k);
1770
+ interactionManager.setTransform(newTransform);
1771
+ needsRender = true;
1772
+ scheduleRender();
1773
+ }
1774
+ function selectNode(nodeId) {
1775
+ if (destroyed) return;
1776
+ selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
1777
+ needsRender = true;
1778
+ scheduleRender();
1779
+ options?.onSelectionChange?.([nodeId]);
1780
+ }
1781
+ function getSelectedNodes() {
1782
+ return [...selectedNodeIds];
1783
+ }
1784
+ function doResize() {
1785
+ if (destroyed || !canvas || !renderer || !wrapper) return;
1786
+ const { width, height } = getContainerDimensions();
1787
+ const chromeHeight = chromeEl?.getBoundingClientRect().height || 0;
1788
+ const canvasHeight = Math.max(height - chromeHeight, 200);
1789
+ renderer.resize(width, canvasHeight);
1790
+ needsRender = true;
1791
+ scheduleRender();
1792
+ }
1793
+ function update(newSpec) {
1794
+ if (destroyed) return;
1795
+ currentSpec = newSpec;
1796
+ teardownSubsystems();
1797
+ compilation = compile();
1798
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
1799
+ renderChrome2();
1800
+ renderLegend2();
1801
+ initSimulation();
1802
+ initInteraction();
1803
+ hoveredNodeId = null;
1804
+ selectedNodeIds = /* @__PURE__ */ new Set();
1805
+ searchManager.clearSearch();
1806
+ }
1807
+ function teardownSubsystems() {
1808
+ if (animFrameId !== null) {
1809
+ cancelAnimationFrame(animFrameId);
1810
+ animFrameId = null;
1811
+ }
1812
+ if (cleanupKeyboard) {
1813
+ cleanupKeyboard();
1814
+ cleanupKeyboard = null;
1815
+ }
1816
+ interactionManager?.destroy();
1817
+ interactionManager = null;
1818
+ simulation?.destroy();
1819
+ simulation = null;
1820
+ tooltipManager?.destroy();
1821
+ tooltipManager = null;
1822
+ }
1823
+ function destroy() {
1824
+ if (destroyed) return;
1825
+ destroyed = true;
1826
+ if (gestureTimeout !== null) {
1827
+ clearTimeout(gestureTimeout);
1828
+ gestureTimeout = null;
1829
+ }
1830
+ teardownSubsystems();
1831
+ if (disconnectResize) {
1832
+ disconnectResize();
1833
+ disconnectResize = null;
1834
+ }
1835
+ if (wrapper?.parentNode) {
1836
+ wrapper.parentNode.removeChild(wrapper);
1837
+ }
1838
+ wrapper = null;
1839
+ canvas = null;
1840
+ chromeEl = null;
1841
+ legendEl = null;
1842
+ renderer = null;
1843
+ container.classList.remove("viz-dark");
1844
+ }
1845
+ try {
1846
+ compilation = compile();
1847
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
1848
+ createDOM();
1849
+ initSimulation();
1850
+ initInteraction();
1851
+ } catch (err) {
1852
+ console.error("[viz] Graph mount failed:", err);
1853
+ return {
1854
+ update() {
1855
+ },
1856
+ search() {
1857
+ },
1858
+ clearSearch() {
1859
+ },
1860
+ zoomToFit() {
1861
+ },
1862
+ zoomToNode() {
1863
+ },
1864
+ selectNode() {
1865
+ },
1866
+ getSelectedNodes: () => [],
1867
+ resize() {
1868
+ },
1869
+ destroy() {
1870
+ }
1871
+ };
1872
+ }
1873
+ if (options?.responsive !== false) {
1874
+ disconnectResize = observeResize(container, () => {
1875
+ doResize();
1876
+ });
1877
+ }
1878
+ return {
1879
+ update,
1880
+ search,
1881
+ clearSearch,
1882
+ zoomToFit,
1883
+ zoomToNode,
1884
+ selectNode,
1885
+ getSelectedNodes,
1886
+ resize: doResize,
1887
+ destroy
1888
+ };
1889
+ }
1890
+ function escapeHtml(str) {
1891
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1892
+ }
1893
+
1894
+ // src/mount.ts
1895
+ import { compileChart } from "@opendata-ai/openchart-engine";
1896
+
1897
+ // src/svg-renderer.ts
1898
+ import { estimateTextWidth } from "@opendata-ai/openchart-core";
1899
+ var SVG_NS = "http://www.w3.org/2000/svg";
1900
+ function createSVGElement(tag) {
1901
+ return document.createElementNS(SVG_NS, tag);
1902
+ }
1903
+ function setAttrs(el, attrs) {
1904
+ for (const [key, value] of Object.entries(attrs)) {
1905
+ el.setAttribute(key, String(value));
1906
+ }
1907
+ }
1908
+ function applyTextStyle(el, style) {
1909
+ setAttrs(el, {
1910
+ "font-family": style.fontFamily,
1911
+ "font-size": style.fontSize,
1912
+ "font-weight": style.fontWeight
1913
+ });
1914
+ el.style.setProperty("fill", style.fill);
1915
+ if (style.textAnchor) {
1916
+ el.setAttribute("text-anchor", style.textAnchor);
1917
+ }
1918
+ if (style.dominantBaseline) {
1919
+ el.setAttribute("dominant-baseline", style.dominantBaseline);
1920
+ }
1921
+ if (style.fontVariant) {
1922
+ el.setAttribute("font-variant", style.fontVariant);
1923
+ }
1924
+ }
1925
+ function renderChromeElement(parent, element, className, chromeKey) {
1926
+ const text = createSVGElement("text");
1927
+ setAttrs(text, { x: element.x, y: element.y });
1928
+ applyTextStyle(text, element.style);
1929
+ text.setAttribute("class", className);
1930
+ text.setAttribute("data-chrome-key", chromeKey);
1931
+ text.textContent = element.text;
1932
+ parent.appendChild(text);
1933
+ }
1934
+ function renderChrome(parent, layout) {
1935
+ const g = createSVGElement("g");
1936
+ g.setAttribute("class", "viz-chrome");
1937
+ const { chrome } = layout;
1938
+ if (chrome.title) {
1939
+ renderChromeElement(g, chrome.title, "viz-title", "title");
1940
+ }
1941
+ if (chrome.subtitle) {
1942
+ renderChromeElement(g, chrome.subtitle, "viz-subtitle", "subtitle");
1943
+ }
1944
+ const xAxisExtent = layout.axes.x ? layout.axes.x.label ? 48 : 26 : 0;
1945
+ const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
1946
+ if (chrome.source) {
1947
+ renderChromeElement(
1948
+ g,
1949
+ { ...chrome.source, y: bottomOffset + chrome.source.y },
1950
+ "viz-source",
1951
+ "source"
1952
+ );
1953
+ }
1954
+ if (chrome.byline) {
1955
+ renderChromeElement(
1956
+ g,
1957
+ { ...chrome.byline, y: bottomOffset + chrome.byline.y },
1958
+ "viz-byline",
1959
+ "byline"
1960
+ );
1961
+ }
1962
+ if (chrome.footer) {
1963
+ renderChromeElement(
1964
+ g,
1965
+ { ...chrome.footer, y: bottomOffset + chrome.footer.y },
1966
+ "viz-footer",
1967
+ "footer"
1968
+ );
1969
+ }
1970
+ parent.appendChild(g);
1971
+ }
1972
+ function renderAxis(parent, axis, orientation, layout) {
1973
+ const g = createSVGElement("g");
1974
+ g.setAttribute("class", `viz-axis viz-axis-${orientation}`);
1975
+ const { area } = layout;
1976
+ if (orientation === "x") {
1977
+ const line = createSVGElement("line");
1978
+ line.setAttribute("class", "viz-axis-line");
1979
+ setAttrs(line, {
1980
+ x1: axis.start.x,
1981
+ y1: axis.start.y,
1982
+ x2: axis.end.x,
1983
+ y2: axis.end.y,
1984
+ stroke: layout.theme.colors.axis,
1985
+ "stroke-width": 1
1986
+ });
1987
+ g.appendChild(line);
1988
+ }
1989
+ for (const tick of axis.ticks) {
1990
+ if (orientation === "x") {
1991
+ const label = createSVGElement("text");
1992
+ label.setAttribute("class", "viz-axis-tick");
1993
+ setAttrs(label, {
1994
+ x: tick.position,
1995
+ y: area.y + area.height + 14,
1996
+ "text-anchor": "middle"
1997
+ });
1998
+ applyTextStyle(label, axis.tickLabelStyle);
1999
+ label.textContent = tick.label;
2000
+ g.appendChild(label);
2001
+ } else {
2002
+ const label = createSVGElement("text");
2003
+ label.setAttribute("class", "viz-axis-tick");
2004
+ setAttrs(label, {
2005
+ x: area.x - 6,
2006
+ y: tick.position,
2007
+ "text-anchor": "end",
2008
+ "dominant-baseline": "central"
2009
+ });
2010
+ applyTextStyle(label, axis.tickLabelStyle);
2011
+ label.textContent = tick.label;
2012
+ g.appendChild(label);
2013
+ }
2014
+ }
2015
+ for (const gridline of axis.gridlines) {
2016
+ const gl = createSVGElement("line");
2017
+ gl.setAttribute("class", "viz-gridline");
2018
+ if (orientation === "y") {
2019
+ setAttrs(gl, {
2020
+ x1: area.x,
2021
+ y1: gridline.position,
2022
+ x2: area.x + area.width,
2023
+ y2: gridline.position,
2024
+ stroke: layout.theme.colors.gridline,
2025
+ "stroke-width": 1,
2026
+ "stroke-opacity": 0.35
2027
+ });
2028
+ } else {
2029
+ setAttrs(gl, {
2030
+ x1: gridline.position,
2031
+ y1: area.y,
2032
+ x2: gridline.position,
2033
+ y2: area.y + area.height,
2034
+ stroke: layout.theme.colors.gridline,
2035
+ "stroke-width": 1,
2036
+ "stroke-opacity": 0.35
2037
+ });
2038
+ }
2039
+ g.appendChild(gl);
2040
+ }
2041
+ if (axis.label && axis.labelStyle) {
2042
+ const axisLabel = createSVGElement("text");
2043
+ axisLabel.setAttribute("class", "viz-axis-title");
2044
+ applyTextStyle(axisLabel, axis.labelStyle);
2045
+ axisLabel.textContent = axis.label;
2046
+ if (orientation === "x") {
2047
+ setAttrs(axisLabel, {
2048
+ x: area.x + area.width / 2,
2049
+ y: area.y + area.height + 35,
2050
+ "text-anchor": "middle"
2051
+ });
2052
+ } else {
2053
+ setAttrs(axisLabel, {
2054
+ x: area.x - 45,
2055
+ y: area.y + area.height / 2,
2056
+ "text-anchor": "middle",
2057
+ transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`
2058
+ });
2059
+ }
2060
+ g.appendChild(axisLabel);
2061
+ }
2062
+ parent.appendChild(g);
2063
+ }
2064
+ function renderAxes(parent, layout) {
2065
+ if (layout.axes.x) {
2066
+ renderAxis(parent, layout.axes.x, "x", layout);
2067
+ }
2068
+ if (layout.axes.y) {
2069
+ renderAxis(parent, layout.axes.y, "y", layout);
2070
+ }
2071
+ }
2072
+ var markRenderers = {};
2073
+ function registerMarkRenderer(type, renderer) {
2074
+ markRenderers[type] = renderer;
2075
+ }
2076
+ function renderLineMark(mark, index) {
2077
+ const g = createSVGElement("g");
2078
+ g.setAttribute("data-mark-id", `line-${mark.seriesKey ?? index}`);
2079
+ g.setAttribute("class", "viz-mark viz-mark-line");
2080
+ if (mark.points.length > 1) {
2081
+ const path = createSVGElement("path");
2082
+ const d = mark.path ?? mark.points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
2083
+ setAttrs(path, {
2084
+ d,
2085
+ fill: "none",
2086
+ stroke: mark.stroke,
2087
+ "stroke-width": mark.strokeWidth
2088
+ });
2089
+ if (mark.strokeDasharray) {
2090
+ path.setAttribute("stroke-dasharray", mark.strokeDasharray);
2091
+ }
2092
+ g.appendChild(path);
2093
+ }
2094
+ if (mark.label?.visible) {
2095
+ const label = createSVGElement("text");
2096
+ label.setAttribute("class", "viz-mark-label");
2097
+ if (mark.seriesKey) {
2098
+ label.setAttribute("data-series", mark.seriesKey);
2099
+ }
2100
+ setAttrs(label, { x: mark.label.x, y: mark.label.y });
2101
+ applyTextStyle(label, mark.label.style);
2102
+ label.textContent = mark.label.text;
2103
+ g.appendChild(label);
2104
+ if (mark.label.connector) {
2105
+ const connector = createSVGElement("line");
2106
+ connector.setAttribute("class", "viz-mark-connector");
2107
+ setAttrs(connector, {
2108
+ x1: mark.label.connector.from.x,
2109
+ y1: mark.label.connector.from.y,
2110
+ x2: mark.label.connector.to.x,
2111
+ y2: mark.label.connector.to.y,
2112
+ stroke: mark.label.connector.stroke,
2113
+ "stroke-width": 1,
2114
+ "stroke-opacity": 0.5
2115
+ });
2116
+ g.appendChild(connector);
2117
+ }
2118
+ }
2119
+ return g;
2120
+ }
2121
+ function renderAreaMark(mark, index) {
2122
+ const g = createSVGElement("g");
2123
+ g.setAttribute("data-mark-id", `area-${mark.seriesKey ?? index}`);
2124
+ g.setAttribute("class", "viz-mark viz-mark-area");
2125
+ if (mark.path) {
2126
+ const fill = createSVGElement("path");
2127
+ setAttrs(fill, {
2128
+ d: mark.path,
2129
+ fill: mark.fill,
2130
+ "fill-opacity": mark.fillOpacity,
2131
+ stroke: "none"
2132
+ });
2133
+ g.appendChild(fill);
2134
+ if (mark.stroke && mark.topPath) {
2135
+ const strokePath = createSVGElement("path");
2136
+ setAttrs(strokePath, {
2137
+ d: mark.topPath,
2138
+ fill: "none",
2139
+ stroke: mark.stroke,
2140
+ "stroke-width": mark.strokeWidth ?? 1
2141
+ });
2142
+ g.appendChild(strokePath);
2143
+ }
2144
+ }
2145
+ return g;
2146
+ }
2147
+ function renderRectMark(mark, index) {
2148
+ const g = createSVGElement("g");
2149
+ g.setAttribute("data-mark-id", `rect-${index}`);
2150
+ g.setAttribute("class", "viz-mark viz-mark-rect");
2151
+ const rect = createSVGElement("rect");
2152
+ setAttrs(rect, {
2153
+ x: mark.x,
2154
+ y: mark.y,
2155
+ width: mark.width,
2156
+ height: mark.height,
2157
+ fill: mark.fill
2158
+ });
2159
+ if (mark.stroke) {
2160
+ rect.setAttribute("stroke", mark.stroke);
2161
+ }
2162
+ if (mark.strokeWidth) {
2163
+ rect.setAttribute("stroke-width", String(mark.strokeWidth));
2164
+ }
2165
+ if (mark.cornerRadius) {
2166
+ setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
2167
+ }
2168
+ g.appendChild(rect);
2169
+ if (mark.label?.visible) {
2170
+ const label = createSVGElement("text");
2171
+ label.setAttribute("class", "viz-mark-label");
2172
+ setAttrs(label, { x: mark.label.x, y: mark.label.y });
2173
+ applyTextStyle(label, mark.label.style);
2174
+ label.textContent = mark.label.text;
2175
+ g.appendChild(label);
2176
+ }
2177
+ return g;
2178
+ }
2179
+ function renderArcMark(mark, index) {
2180
+ const g = createSVGElement("g");
2181
+ g.setAttribute("data-mark-id", `arc-${index}`);
2182
+ g.setAttribute("class", "viz-mark viz-mark-arc");
2183
+ g.setAttribute("transform", `translate(${mark.center.x},${mark.center.y})`);
2184
+ const path = createSVGElement("path");
2185
+ setAttrs(path, {
2186
+ d: mark.path,
2187
+ fill: mark.fill,
2188
+ stroke: mark.stroke,
2189
+ "stroke-width": mark.strokeWidth
2190
+ });
2191
+ g.appendChild(path);
2192
+ if (mark.label?.visible) {
2193
+ const label = createSVGElement("text");
2194
+ label.setAttribute("class", "viz-mark-label");
2195
+ setAttrs(label, {
2196
+ x: mark.label.x - mark.center.x,
2197
+ y: mark.label.y - mark.center.y
2198
+ });
2199
+ applyTextStyle(label, mark.label.style);
2200
+ label.textContent = mark.label.text;
2201
+ g.appendChild(label);
2202
+ }
2203
+ return g;
2204
+ }
2205
+ function renderPointMark(mark, index) {
2206
+ const circle = createSVGElement("circle");
2207
+ circle.setAttribute("data-mark-id", `point-${index}`);
2208
+ circle.setAttribute("class", "viz-mark viz-mark-point");
2209
+ setAttrs(circle, {
2210
+ cx: mark.cx,
2211
+ cy: mark.cy,
2212
+ r: mark.r,
2213
+ fill: mark.fill,
2214
+ stroke: mark.stroke,
2215
+ "stroke-width": mark.strokeWidth
2216
+ });
2217
+ if (mark.fillOpacity !== void 0) {
2218
+ circle.setAttribute("fill-opacity", String(mark.fillOpacity));
2219
+ }
2220
+ return circle;
2221
+ }
2222
+ registerMarkRenderer("line", renderLineMark);
2223
+ registerMarkRenderer("area", renderAreaMark);
2224
+ registerMarkRenderer("rect", renderRectMark);
2225
+ registerMarkRenderer("arc", renderArcMark);
2226
+ registerMarkRenderer("point", renderPointMark);
2227
+ function getMarkSeries(mark) {
2228
+ if (mark.type === "line" || mark.type === "area") {
2229
+ return mark.seriesKey;
2230
+ }
2231
+ if (mark.type === "arc") {
2232
+ return mark.aria.label.split(":")[0]?.trim();
2233
+ }
2234
+ if (mark.aria?.label) {
2235
+ const beforeColon = mark.aria.label.split(":")[0]?.trim();
2236
+ if (beforeColon) return beforeColon;
2237
+ }
2238
+ return void 0;
2239
+ }
2240
+ function renderMarks(parent, layout) {
2241
+ const g = createSVGElement("g");
2242
+ g.setAttribute("class", "viz-marks");
2243
+ for (let i = 0; i < layout.marks.length; i++) {
2244
+ const mark = layout.marks[i];
2245
+ const renderer = markRenderers[mark.type];
2246
+ if (renderer) {
2247
+ const el = renderer(mark, i);
2248
+ if (mark.aria?.label) {
2249
+ el.setAttribute("aria-label", mark.aria.label);
2250
+ }
2251
+ const series = getMarkSeries(mark);
2252
+ if (series) {
2253
+ el.setAttribute("data-series", series);
2254
+ }
2255
+ g.appendChild(el);
2256
+ }
2257
+ }
2258
+ parent.appendChild(g);
2259
+ }
2260
+ function renderAnnotations(parent, layout) {
2261
+ if (layout.annotations.length === 0) return;
2262
+ const g = createSVGElement("g");
2263
+ g.setAttribute("class", "viz-annotations");
2264
+ for (let i = 0; i < layout.annotations.length; i++) {
2265
+ renderAnnotation(g, layout.annotations[i], i);
2266
+ }
2267
+ parent.appendChild(g);
2268
+ }
2269
+ function renderCurvedArrow(parent, from, to, stroke) {
2270
+ const pad = 6;
2271
+ const tipY = to.y - pad;
2272
+ const dy = tipY - from.y;
2273
+ const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
2274
+ const arrowLen = 8;
2275
+ const arrowWidth = 4;
2276
+ const bulge = Math.max(dist * 0.4, 35);
2277
+ const cp1x = from.x + bulge;
2278
+ const cp1y = from.y + dy * 0.35;
2279
+ const cp2x = to.x;
2280
+ const cp2y = tipY - Math.abs(dy) * 0.25;
2281
+ const tx = to.x - cp2x;
2282
+ const ty = tipY - cp2y;
2283
+ const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
2284
+ const ux = tx / tLen;
2285
+ const uy = ty / tLen;
2286
+ const baseX = to.x - ux * arrowLen;
2287
+ const baseY = tipY - uy * arrowLen;
2288
+ const path = createSVGElement("path");
2289
+ path.setAttribute("class", "viz-annotation-connector");
2290
+ setAttrs(path, {
2291
+ d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
2292
+ fill: "none",
2293
+ stroke,
2294
+ "stroke-width": 1.5
2295
+ });
2296
+ parent.appendChild(path);
2297
+ const px = -uy;
2298
+ const py = ux;
2299
+ const arrow = createSVGElement("polygon");
2300
+ arrow.setAttribute("class", "viz-annotation-connector");
2301
+ setAttrs(arrow, {
2302
+ points: [
2303
+ `${to.x},${tipY}`,
2304
+ `${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
2305
+ `${baseX - px * arrowWidth},${baseY - py * arrowWidth}`
2306
+ ].join(" "),
2307
+ fill: stroke
2308
+ });
2309
+ parent.appendChild(arrow);
2310
+ }
2311
+ function renderAnnotation(parent, annotation, index) {
2312
+ const g = createSVGElement("g");
2313
+ g.setAttribute("class", `viz-annotation viz-annotation-${annotation.type}`);
2314
+ g.setAttribute("data-annotation-index", String(index));
2315
+ if (annotation.rect) {
2316
+ const rect = createSVGElement("rect");
2317
+ rect.setAttribute("class", "viz-annotation-range");
2318
+ setAttrs(rect, {
2319
+ x: annotation.rect.x,
2320
+ y: annotation.rect.y,
2321
+ width: annotation.rect.width,
2322
+ height: annotation.rect.height
2323
+ });
2324
+ if (annotation.fill) rect.setAttribute("fill", annotation.fill);
2325
+ if (annotation.opacity !== void 0) {
2326
+ rect.setAttribute("fill-opacity", String(annotation.opacity));
2327
+ }
2328
+ g.appendChild(rect);
2329
+ }
2330
+ if (annotation.line) {
2331
+ const line = createSVGElement("line");
2332
+ line.setAttribute("class", "viz-annotation-line");
2333
+ setAttrs(line, {
2334
+ x1: annotation.line.start.x,
2335
+ y1: annotation.line.start.y,
2336
+ x2: annotation.line.end.x,
2337
+ y2: annotation.line.end.y,
2338
+ "stroke-width": annotation.strokeWidth ?? 1
2339
+ });
2340
+ if (annotation.stroke) line.setAttribute("stroke", annotation.stroke);
2341
+ if (annotation.strokeDasharray) {
2342
+ line.setAttribute("stroke-dasharray", annotation.strokeDasharray);
2343
+ }
2344
+ g.appendChild(line);
2345
+ }
2346
+ if (annotation.label?.visible) {
2347
+ if (annotation.label.connector) {
2348
+ const c = annotation.label.connector;
2349
+ if (c.style === "caret") {
2350
+ const pointsDown = c.to.y > c.from.y;
2351
+ const caretSize = 4;
2352
+ const labelLines = annotation.label.text.split("\n");
2353
+ const labelFontSize = annotation.label.style.fontSize ?? 12;
2354
+ const labelLineHeight = labelFontSize * (annotation.label.style.lineHeight ?? 1.3);
2355
+ const textBottom = annotation.label.y + (labelLines.length - 1) * labelLineHeight + labelFontSize * 0.25;
2356
+ const textTop = annotation.label.y - labelFontSize;
2357
+ const gapEdge = pointsDown ? textBottom : textTop;
2358
+ const midY = (gapEdge + c.to.y) / 2;
2359
+ const tipX = c.to.x;
2360
+ const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
2361
+ const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
2362
+ const path = createSVGElement("path");
2363
+ path.setAttribute("class", "viz-annotation-connector");
2364
+ setAttrs(path, {
2365
+ d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
2366
+ fill: "none",
2367
+ stroke: c.stroke,
2368
+ "stroke-width": 1.5,
2369
+ "stroke-opacity": 0.4,
2370
+ "stroke-linecap": "round",
2371
+ "stroke-linejoin": "round"
2372
+ });
2373
+ g.appendChild(path);
2374
+ } else if (c.style === "curve") {
2375
+ renderCurvedArrow(g, c.from, c.to, c.stroke);
2376
+ } else {
2377
+ const connector = createSVGElement("line");
2378
+ connector.setAttribute("class", "viz-annotation-connector");
2379
+ setAttrs(connector, {
2380
+ x1: c.from.x,
2381
+ y1: c.from.y,
2382
+ x2: c.to.x,
2383
+ y2: c.to.y,
2384
+ stroke: c.stroke,
2385
+ "stroke-width": 1,
2386
+ "stroke-opacity": 0.5
2387
+ });
2388
+ g.appendChild(connector);
2389
+ }
2390
+ }
2391
+ const text = createSVGElement("text");
2392
+ text.setAttribute("class", "viz-annotation-label");
2393
+ setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
2394
+ applyTextStyle(text, annotation.label.style);
2395
+ const lines = annotation.label.text.split("\n");
2396
+ const fontSize = annotation.label.style.fontSize ?? 12;
2397
+ const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
2398
+ const isMultiLine = lines.length > 1;
2399
+ if (isMultiLine) {
2400
+ text.setAttribute("text-anchor", "middle");
2401
+ for (let i = 0; i < lines.length; i++) {
2402
+ const tspan = createSVGElement("tspan");
2403
+ setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
2404
+ tspan.textContent = lines[i];
2405
+ text.appendChild(tspan);
2406
+ }
2407
+ } else {
2408
+ text.textContent = annotation.label.text;
2409
+ }
2410
+ if (annotation.label.background) {
2411
+ const charWidth = fontSize * 0.55;
2412
+ const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
2413
+ const totalHeight = lines.length * lineHeight;
2414
+ const pad = 3;
2415
+ const bgX = isMultiLine ? annotation.label.x - maxLineWidth / 2 - pad : annotation.label.x - pad;
2416
+ const bgRect = createSVGElement("rect");
2417
+ bgRect.setAttribute("class", "viz-annotation-bg");
2418
+ setAttrs(bgRect, {
2419
+ x: bgX,
2420
+ y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
2421
+ width: maxLineWidth + pad * 2,
2422
+ height: totalHeight + pad * 2,
2423
+ fill: annotation.label.background,
2424
+ rx: 2
2425
+ });
2426
+ g.appendChild(bgRect);
2427
+ }
2428
+ g.appendChild(text);
2429
+ }
2430
+ parent.appendChild(g);
2431
+ }
2432
+ function renderLegend(parent, legend) {
2433
+ if (legend.entries.length === 0) return;
2434
+ const g = createSVGElement("g");
2435
+ g.setAttribute("class", "viz-legend");
2436
+ g.setAttribute("role", "list");
2437
+ g.setAttribute("aria-label", "Chart legend");
2438
+ const isHorizontal = legend.position === "top" || legend.position === "bottom";
2439
+ let offsetX = legend.bounds.x;
2440
+ let offsetY = legend.bounds.y;
2441
+ for (let i = 0; i < legend.entries.length; i++) {
2442
+ const entry = legend.entries[i];
2443
+ const entryG = createSVGElement("g");
2444
+ entryG.setAttribute("class", "viz-legend-entry");
2445
+ entryG.setAttribute("role", "listitem");
2446
+ entryG.setAttribute("data-legend-index", String(i));
2447
+ entryG.setAttribute("data-legend-label", entry.label);
2448
+ entryG.setAttribute(
2449
+ "aria-label",
2450
+ `${entry.label}: ${entry.active !== false ? "visible" : "hidden"}`
2451
+ );
2452
+ entryG.setAttribute("style", "cursor: pointer");
2453
+ if (entry.active === false) {
2454
+ entryG.setAttribute("opacity", "0.3");
2455
+ }
2456
+ if (entry.shape === "circle") {
2457
+ const circle = createSVGElement("circle");
2458
+ setAttrs(circle, {
2459
+ cx: offsetX + legend.swatchSize / 2,
2460
+ cy: offsetY + legend.swatchSize / 2,
2461
+ r: legend.swatchSize / 2,
2462
+ fill: entry.color
2463
+ });
2464
+ entryG.appendChild(circle);
2465
+ } else if (entry.shape === "line") {
2466
+ const line = createSVGElement("line");
2467
+ setAttrs(line, {
2468
+ x1: offsetX,
2469
+ y1: offsetY + legend.swatchSize / 2,
2470
+ x2: offsetX + legend.swatchSize,
2471
+ y2: offsetY + legend.swatchSize / 2,
2472
+ stroke: entry.color,
2473
+ "stroke-width": 2
2474
+ });
2475
+ entryG.appendChild(line);
2476
+ const dot = createSVGElement("circle");
2477
+ setAttrs(dot, {
2478
+ cx: offsetX + legend.swatchSize / 2,
2479
+ cy: offsetY + legend.swatchSize / 2,
2480
+ r: 2.5,
2481
+ fill: entry.color
2482
+ });
2483
+ entryG.appendChild(dot);
2484
+ } else {
2485
+ const rect = createSVGElement("rect");
2486
+ setAttrs(rect, {
2487
+ x: offsetX,
2488
+ y: offsetY,
2489
+ width: legend.swatchSize,
2490
+ height: legend.swatchSize,
2491
+ fill: entry.color,
2492
+ rx: 2
2493
+ });
2494
+ entryG.appendChild(rect);
2495
+ }
2496
+ const label = createSVGElement("text");
2497
+ setAttrs(label, {
2498
+ x: offsetX + legend.swatchSize + legend.swatchGap,
2499
+ y: offsetY + legend.swatchSize / 2,
2500
+ "dominant-baseline": "central"
2501
+ });
2502
+ applyTextStyle(label, legend.labelStyle);
2503
+ label.textContent = entry.label;
2504
+ entryG.appendChild(label);
2505
+ g.appendChild(entryG);
2506
+ if (isHorizontal) {
2507
+ const labelWidth = estimateTextWidth(
2508
+ entry.label,
2509
+ legend.labelStyle.fontSize,
2510
+ legend.labelStyle.fontWeight
2511
+ );
2512
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
2513
+ offsetX += entryWidth;
2514
+ if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
2515
+ offsetX = legend.bounds.x;
2516
+ offsetY += legend.swatchSize + 6;
2517
+ }
2518
+ } else {
2519
+ offsetY += legend.swatchSize + legend.entryGap;
2520
+ }
2521
+ }
2522
+ parent.appendChild(g);
2523
+ }
2524
+ function renderChartSVG(layout, container) {
2525
+ const { width, height } = layout.dimensions;
2526
+ const svg = createSVGElement("svg");
2527
+ setAttrs(svg, {
2528
+ viewBox: `0 0 ${width} ${height}`,
2529
+ xmlns: SVG_NS
2530
+ });
2531
+ svg.setAttribute("role", layout.a11y.role);
2532
+ svg.setAttribute("aria-label", layout.a11y.altText);
2533
+ svg.setAttribute("class", "viz-chart");
2534
+ const bg = createSVGElement("rect");
2535
+ setAttrs(bg, {
2536
+ x: 0,
2537
+ y: 0,
2538
+ width,
2539
+ height,
2540
+ fill: layout.theme.colors.background
2541
+ });
2542
+ svg.appendChild(bg);
2543
+ const clipId = `viz-clip-${Math.random().toString(36).slice(2, 8)}`;
2544
+ const defs = createSVGElement("defs");
2545
+ const clipPath = createSVGElement("clipPath");
2546
+ clipPath.setAttribute("id", clipId);
2547
+ const clipRect = createSVGElement("rect");
2548
+ setAttrs(clipRect, {
2549
+ x: 0,
2550
+ y: layout.area.y,
2551
+ width,
2552
+ height: layout.area.height + 2
2553
+ });
2554
+ clipPath.appendChild(clipRect);
2555
+ defs.appendChild(clipPath);
2556
+ svg.appendChild(defs);
2557
+ renderAxes(svg, layout);
2558
+ const clippedGroup = createSVGElement("g");
2559
+ clippedGroup.setAttribute("clip-path", `url(#${clipId})`);
2560
+ renderMarks(clippedGroup, layout);
2561
+ svg.appendChild(clippedGroup);
2562
+ renderAnnotations(svg, layout);
2563
+ renderLegend(svg, layout.legend);
2564
+ renderChrome(svg, layout);
2565
+ container.appendChild(svg);
2566
+ return svg;
2567
+ }
2568
+
2569
+ // src/mount.ts
2570
+ function resolveDarkMode2(mode) {
2571
+ if (mode === "force") return true;
2572
+ if (mode === "off" || mode === void 0) return false;
2573
+ if (typeof window !== "undefined" && window.matchMedia) {
2574
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
2575
+ }
2576
+ return false;
2577
+ }
2578
+ function createMeasureText() {
2579
+ let canvas = null;
2580
+ let ctx = null;
2581
+ return (text, fontSize, fontWeight) => {
2582
+ if (!canvas) {
2583
+ canvas = document.createElement("canvas");
2584
+ ctx = canvas.getContext("2d");
2585
+ }
2586
+ if (!ctx) {
2587
+ return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
2588
+ }
2589
+ const weight = fontWeight ?? 400;
2590
+ ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
2591
+ const metrics = ctx.measureText(text);
2592
+ return {
2593
+ width: metrics.width,
2594
+ height: fontSize * 1.2
2595
+ };
2596
+ };
2597
+ }
2598
+ function wireTooltipEvents(svg, tooltipDescriptors, tooltipManager) {
2599
+ const markElements = svg.querySelectorAll("[data-mark-id]");
2600
+ const cleanups = [];
2601
+ for (const el of markElements) {
2602
+ const markId = el.getAttribute("data-mark-id");
2603
+ if (!markId) continue;
2604
+ const content = tooltipDescriptors.get(markId);
2605
+ if (!content) continue;
2606
+ const handleMouseEnter = (e) => {
2607
+ const mouseEvent = e;
2608
+ const svgRect = svg.getBoundingClientRect();
2609
+ const x = mouseEvent.clientX - svgRect.left;
2610
+ const y = mouseEvent.clientY - svgRect.top;
2611
+ tooltipManager.show(content, x, y);
2612
+ };
2613
+ const handleMouseMove = (e) => {
2614
+ const mouseEvent = e;
2615
+ const svgRect = svg.getBoundingClientRect();
2616
+ const x = mouseEvent.clientX - svgRect.left;
2617
+ const y = mouseEvent.clientY - svgRect.top;
2618
+ tooltipManager.show(content, x, y);
2619
+ };
2620
+ const handleMouseLeave = () => {
2621
+ tooltipManager.hide();
2622
+ };
2623
+ const handleTouchStart = (e) => {
2624
+ const touchEvent = e;
2625
+ if (touchEvent.touches.length > 0) {
2626
+ const touch = touchEvent.touches[0];
2627
+ const svgRect = svg.getBoundingClientRect();
2628
+ const x = touch.clientX - svgRect.left;
2629
+ const y = touch.clientY - svgRect.top;
2630
+ tooltipManager.show(content, x, y);
2631
+ }
2632
+ };
2633
+ el.addEventListener("mouseenter", handleMouseEnter);
2634
+ el.addEventListener("mousemove", handleMouseMove);
2635
+ el.addEventListener("mouseleave", handleMouseLeave);
2636
+ el.addEventListener("touchstart", handleTouchStart);
2637
+ cleanups.push(() => {
2638
+ el.removeEventListener("mouseenter", handleMouseEnter);
2639
+ el.removeEventListener("mousemove", handleMouseMove);
2640
+ el.removeEventListener("mouseleave", handleMouseLeave);
2641
+ el.removeEventListener("touchstart", handleTouchStart);
2642
+ });
2643
+ }
2644
+ return () => {
2645
+ for (const cleanup of cleanups) {
2646
+ cleanup();
2647
+ }
2648
+ };
2649
+ }
2650
+ function buildMarkDataMap(layout) {
2651
+ const map = /* @__PURE__ */ new Map();
2652
+ for (let i = 0; i < layout.marks.length; i++) {
2653
+ const mark = layout.marks[i];
2654
+ switch (mark.type) {
2655
+ case "line":
2656
+ map.set(`line-${mark.seriesKey ?? i}`, {
2657
+ // For line marks, data is an array. Use the first row as representative.
2658
+ datum: mark.data[0] ?? {},
2659
+ series: mark.seriesKey
2660
+ });
2661
+ break;
2662
+ case "area":
2663
+ map.set(`area-${mark.seriesKey ?? i}`, {
2664
+ datum: mark.data[0] ?? {},
2665
+ series: mark.seriesKey
2666
+ });
2667
+ break;
2668
+ case "rect":
2669
+ map.set(`rect-${i}`, { datum: mark.data });
2670
+ break;
2671
+ case "arc":
2672
+ map.set(`arc-${i}`, { datum: mark.data });
2673
+ break;
2674
+ case "point":
2675
+ map.set(`point-${i}`, { datum: mark.data });
2676
+ break;
2677
+ }
2678
+ }
2679
+ return map;
2680
+ }
2681
+ function wireChartEvents(svg, layout, specAnnotations, handlers) {
2682
+ const cleanups = [];
2683
+ const markDataMap = buildMarkDataMap(layout);
2684
+ if (handlers.onMarkClick || handlers.onMarkHover || handlers.onMarkLeave) {
2685
+ const markElements = svg.querySelectorAll("[data-mark-id]");
2686
+ for (const el of markElements) {
2687
+ const markId = el.getAttribute("data-mark-id");
2688
+ if (!markId) continue;
2689
+ const markInfo = markDataMap.get(markId);
2690
+ if (!markInfo) continue;
2691
+ const series = markInfo.series ?? el.getAttribute("data-series") ?? void 0;
2692
+ if (handlers.onMarkClick) {
2693
+ const handleClick = (e) => {
2694
+ const mouseEvent = e;
2695
+ const svgRect = svg.getBoundingClientRect();
2696
+ handlers.onMarkClick({
2697
+ datum: markInfo.datum,
2698
+ series,
2699
+ position: {
2700
+ x: mouseEvent.clientX - svgRect.left,
2701
+ y: mouseEvent.clientY - svgRect.top
2702
+ },
2703
+ event: mouseEvent
2704
+ });
2705
+ };
2706
+ el.addEventListener("click", handleClick);
2707
+ cleanups.push(() => el.removeEventListener("click", handleClick));
2708
+ }
2709
+ if (handlers.onMarkHover) {
2710
+ const handleEnter = (e) => {
2711
+ const mouseEvent = e;
2712
+ const svgRect = svg.getBoundingClientRect();
2713
+ handlers.onMarkHover({
2714
+ datum: markInfo.datum,
2715
+ series,
2716
+ position: {
2717
+ x: mouseEvent.clientX - svgRect.left,
2718
+ y: mouseEvent.clientY - svgRect.top
2719
+ },
2720
+ event: mouseEvent
2721
+ });
2722
+ };
2723
+ el.addEventListener("mouseenter", handleEnter);
2724
+ cleanups.push(() => el.removeEventListener("mouseenter", handleEnter));
2725
+ }
2726
+ if (handlers.onMarkLeave) {
2727
+ const handleLeave = () => {
2728
+ handlers.onMarkLeave();
2729
+ };
2730
+ el.addEventListener("mouseleave", handleLeave);
2731
+ cleanups.push(() => el.removeEventListener("mouseleave", handleLeave));
2732
+ }
2733
+ }
2734
+ }
2735
+ if (handlers.onAnnotationClick) {
2736
+ const annotationElements = svg.querySelectorAll(".viz-annotation");
2737
+ for (let i = 0; i < annotationElements.length; i++) {
2738
+ const el = annotationElements[i];
2739
+ const specAnnotation = specAnnotations[i];
2740
+ if (!specAnnotation) continue;
2741
+ const handleClick = (e) => {
2742
+ const mouseEvent = e;
2743
+ handlers.onAnnotationClick(specAnnotation, mouseEvent);
2744
+ };
2745
+ el.addEventListener("click", handleClick);
2746
+ cleanups.push(() => el.removeEventListener("click", handleClick));
2747
+ }
2748
+ }
2749
+ return () => {
2750
+ for (const cleanup of cleanups) {
2751
+ cleanup();
2752
+ }
2753
+ };
2754
+ }
2755
+ function createDragHandler(config) {
2756
+ const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config;
2757
+ const cleanups = [];
2758
+ let activeDocMouseMove = null;
2759
+ let activeDocMouseUp = null;
2760
+ let activeDocTouchMove = null;
2761
+ let activeDocTouchEnd = null;
2762
+ let activeDocTouchCancel = null;
2763
+ function getScale() {
2764
+ const viewBox = svg.viewBox?.baseVal;
2765
+ const svgRect = svg.getBoundingClientRect();
2766
+ return {
2767
+ scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1,
2768
+ scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1
2769
+ };
2770
+ }
2771
+ function startDrag(startX, startY) {
2772
+ setDragging(true);
2773
+ const { scaleX, scaleY } = getScale();
2774
+ element.style.cursor = "grabbing";
2775
+ svg.style.userSelect = "none";
2776
+ const handleMove = (clientX, clientY) => {
2777
+ const dx = (clientX - startX) * scaleX;
2778
+ const dy = (clientY - startY) * scaleY;
2779
+ onMove(dx, dy);
2780
+ };
2781
+ const cleanupDocListeners = () => {
2782
+ if (activeDocMouseMove) {
2783
+ document.removeEventListener("mousemove", activeDocMouseMove);
2784
+ activeDocMouseMove = null;
2785
+ }
2786
+ if (activeDocMouseUp) {
2787
+ document.removeEventListener("mouseup", activeDocMouseUp);
2788
+ activeDocMouseUp = null;
2789
+ }
2790
+ if (activeDocTouchMove) {
2791
+ document.removeEventListener("touchmove", activeDocTouchMove);
2792
+ activeDocTouchMove = null;
2793
+ }
2794
+ if (activeDocTouchEnd) {
2795
+ document.removeEventListener("touchend", activeDocTouchEnd);
2796
+ activeDocTouchEnd = null;
2797
+ }
2798
+ if (activeDocTouchCancel) {
2799
+ document.removeEventListener("touchcancel", activeDocTouchCancel);
2800
+ activeDocTouchCancel = null;
2801
+ }
2802
+ };
2803
+ const handleEnd = (clientX, clientY) => {
2804
+ const dx = (clientX - startX) * scaleX;
2805
+ const dy = (clientY - startY) * scaleY;
2806
+ const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold;
2807
+ onEnd(dx, dy, moved);
2808
+ if (moved) {
2809
+ element.addEventListener(
2810
+ "click",
2811
+ (clickE) => {
2812
+ clickE.stopPropagation();
2813
+ },
2814
+ { capture: true, once: true }
2815
+ );
2816
+ }
2817
+ element.style.cursor = "grab";
2818
+ svg.style.userSelect = "";
2819
+ cleanupDocListeners();
2820
+ setDragging(false);
2821
+ };
2822
+ const onMouseMove = (moveEvent) => {
2823
+ handleMove(moveEvent.clientX, moveEvent.clientY);
2824
+ };
2825
+ const onMouseUp = (upEvent) => {
2826
+ handleEnd(upEvent.clientX, upEvent.clientY);
2827
+ };
2828
+ document.addEventListener("mousemove", onMouseMove);
2829
+ document.addEventListener("mouseup", onMouseUp);
2830
+ activeDocMouseMove = onMouseMove;
2831
+ activeDocMouseUp = onMouseUp;
2832
+ const onTouchMove = (moveEvent) => {
2833
+ if (moveEvent.touches.length > 0) {
2834
+ moveEvent.preventDefault();
2835
+ handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY);
2836
+ }
2837
+ };
2838
+ const onTouchEnd = (endEvent) => {
2839
+ const touch = endEvent.changedTouches[0];
2840
+ if (touch) {
2841
+ handleEnd(touch.clientX, touch.clientY);
2842
+ } else {
2843
+ handleEnd(startX, startY);
2844
+ }
2845
+ };
2846
+ document.addEventListener("touchmove", onTouchMove, { passive: false });
2847
+ document.addEventListener("touchend", onTouchEnd);
2848
+ document.addEventListener("touchcancel", onTouchEnd);
2849
+ activeDocTouchMove = onTouchMove;
2850
+ activeDocTouchEnd = onTouchEnd;
2851
+ activeDocTouchCancel = onTouchEnd;
2852
+ }
2853
+ const handleMouseDown = (e) => {
2854
+ const mouseEvent = e;
2855
+ mouseEvent.preventDefault();
2856
+ startDrag(mouseEvent.clientX, mouseEvent.clientY);
2857
+ };
2858
+ const handleTouchStart = (e) => {
2859
+ const touchEvent = e;
2860
+ if (touchEvent.touches.length === 1) {
2861
+ touchEvent.preventDefault();
2862
+ startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY);
2863
+ }
2864
+ };
2865
+ element.addEventListener("mousedown", handleMouseDown);
2866
+ element.addEventListener("touchstart", handleTouchStart, { passive: false });
2867
+ cleanups.push(() => {
2868
+ element.removeEventListener("mousedown", handleMouseDown);
2869
+ element.removeEventListener("touchstart", handleTouchStart);
2870
+ });
2871
+ return () => {
2872
+ for (const cleanup of cleanups) {
2873
+ cleanup();
2874
+ }
2875
+ if (activeDocMouseMove) {
2876
+ document.removeEventListener("mousemove", activeDocMouseMove);
2877
+ activeDocMouseMove = null;
2878
+ }
2879
+ if (activeDocMouseUp) {
2880
+ document.removeEventListener("mouseup", activeDocMouseUp);
2881
+ activeDocMouseUp = null;
2882
+ }
2883
+ if (activeDocTouchMove) {
2884
+ document.removeEventListener("touchmove", activeDocTouchMove);
2885
+ activeDocTouchMove = null;
2886
+ }
2887
+ if (activeDocTouchEnd) {
2888
+ document.removeEventListener("touchend", activeDocTouchEnd);
2889
+ activeDocTouchEnd = null;
2890
+ }
2891
+ if (activeDocTouchCancel) {
2892
+ document.removeEventListener("touchcancel", activeDocTouchCancel);
2893
+ activeDocTouchCancel = null;
2894
+ }
2895
+ svg.style.userSelect = "";
2896
+ };
2897
+ }
2898
+ function wireAnnotationDrag(svg, specAnnotations, onAnnotationEdit, onEdit, setDragging) {
2899
+ const annotationElements = svg.querySelectorAll(".viz-annotation-text");
2900
+ const cleanups = [];
2901
+ for (const el of annotationElements) {
2902
+ const indexStr = el.getAttribute("data-annotation-index");
2903
+ if (indexStr === null) continue;
2904
+ const index = Number(indexStr);
2905
+ const specAnnotation = specAnnotations[index];
2906
+ if (!specAnnotation || specAnnotation.type !== "text") continue;
2907
+ const textAnnotation = specAnnotation;
2908
+ const annotationG = el;
2909
+ annotationG.style.cursor = "grab";
2910
+ const connectorLine = annotationG.querySelector("line.viz-annotation-connector");
2911
+ const origX2 = connectorLine ? Number(connectorLine.getAttribute("x2")) : 0;
2912
+ const origY2 = connectorLine ? Number(connectorLine.getAttribute("y2")) : 0;
2913
+ const curvedPath = annotationG.querySelector("path.viz-annotation-connector");
2914
+ const arrowhead = annotationG.querySelector("polygon.viz-annotation-connector");
2915
+ const hasCurvedConnector = curvedPath !== null;
2916
+ const origDx = textAnnotation.offset?.dx ?? 0;
2917
+ const origDy = textAnnotation.offset?.dy ?? 0;
2918
+ const cleanup = createDragHandler({
2919
+ element: annotationG,
2920
+ svg,
2921
+ onMove: (dx, dy) => {
2922
+ annotationG.setAttribute("transform", `translate(${dx}, ${dy})`);
2923
+ if (connectorLine && !hasCurvedConnector) {
2924
+ connectorLine.setAttribute("x2", String(origX2 - dx));
2925
+ connectorLine.setAttribute("y2", String(origY2 - dy));
2926
+ }
2927
+ if (hasCurvedConnector) {
2928
+ if (curvedPath) curvedPath.setAttribute("display", "none");
2929
+ if (arrowhead) arrowhead.setAttribute("display", "none");
2930
+ }
2931
+ },
2932
+ onEnd: (dx, dy, moved) => {
2933
+ annotationG.removeAttribute("transform");
2934
+ if (connectorLine && !hasCurvedConnector) {
2935
+ connectorLine.setAttribute("x2", String(origX2));
2936
+ connectorLine.setAttribute("y2", String(origY2));
2937
+ }
2938
+ if (hasCurvedConnector) {
2939
+ if (curvedPath) curvedPath.removeAttribute("display");
2940
+ if (arrowhead) arrowhead.removeAttribute("display");
2941
+ }
2942
+ if (moved) {
2943
+ const newOffset = {
2944
+ dx: origDx + dx,
2945
+ dy: origDy + dy
2946
+ };
2947
+ onAnnotationEdit?.(textAnnotation, newOffset);
2948
+ onEdit?.({ type: "annotation", annotation: textAnnotation, offset: newOffset });
2949
+ }
2950
+ },
2951
+ setDragging
2952
+ });
2953
+ cleanups.push(cleanup);
2954
+ }
2955
+ return () => {
2956
+ for (const cleanup of cleanups) {
2957
+ cleanup();
2958
+ }
2959
+ };
2960
+ }
2961
+ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
2962
+ const SVG_NS2 = "http://www.w3.org/2000/svg";
2963
+ const cleanups = [];
2964
+ const annotationGroups = svg.querySelectorAll(".viz-annotation-text");
2965
+ for (const el of annotationGroups) {
2966
+ const annotationG = el;
2967
+ const indexStr = annotationG.getAttribute("data-annotation-index");
2968
+ if (indexStr === null) continue;
2969
+ const index = Number(indexStr);
2970
+ const specAnnotation = specAnnotations[index];
2971
+ if (!specAnnotation || specAnnotation.type !== "text") continue;
2972
+ const textAnnotation = specAnnotation;
2973
+ const connectorLine = annotationG.querySelector("line.viz-annotation-connector");
2974
+ const curvedPath = annotationG.querySelector("path.viz-annotation-connector");
2975
+ if (!connectorLine && !curvedPath) continue;
2976
+ let fromX, fromY, toX, toY;
2977
+ if (connectorLine) {
2978
+ fromX = Number(connectorLine.getAttribute("x1"));
2979
+ fromY = Number(connectorLine.getAttribute("y1"));
2980
+ toX = Number(connectorLine.getAttribute("x2"));
2981
+ toY = Number(connectorLine.getAttribute("y2"));
2982
+ } else {
2983
+ const pathD = curvedPath.getAttribute("d") ?? "";
2984
+ const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
2985
+ fromX = mMatch ? Number(mMatch[1]) : 0;
2986
+ fromY = mMatch ? Number(mMatch[2]) : 0;
2987
+ const arrowhead = annotationG.querySelector("polygon.viz-annotation-connector");
2988
+ const points = arrowhead?.getAttribute("points") ?? "";
2989
+ const firstPoint = points.split(" ")[0] ?? "0,0";
2990
+ const [px, py] = firstPoint.split(",");
2991
+ toX = Number(px);
2992
+ toY = Number(py);
2993
+ }
2994
+ const endpoints = [
2995
+ { name: "from", cx: fromX, cy: fromY },
2996
+ { name: "to", cx: toX, cy: toY }
2997
+ ];
2998
+ const createdHandles = [];
2999
+ for (const ep of endpoints) {
3000
+ const handleEl = document.createElementNS(SVG_NS2, "circle");
3001
+ handleEl.setAttribute("class", "viz-connector-handle");
3002
+ handleEl.setAttribute("data-endpoint", ep.name);
3003
+ handleEl.setAttribute("cx", String(ep.cx));
3004
+ handleEl.setAttribute("cy", String(ep.cy));
3005
+ handleEl.setAttribute("r", "4");
3006
+ handleEl.setAttribute("opacity", "0");
3007
+ handleEl.setAttribute("fill", "currentColor");
3008
+ handleEl.setAttribute("stroke", "currentColor");
3009
+ annotationG.appendChild(handleEl);
3010
+ createdHandles.push(handleEl);
3011
+ const origCx = ep.cx;
3012
+ const origCy = ep.cy;
3013
+ const stopProp = (e) => {
3014
+ e.stopPropagation();
3015
+ };
3016
+ handleEl.addEventListener("mousedown", stopProp);
3017
+ handleEl.addEventListener("touchstart", stopProp);
3018
+ cleanups.push(() => {
3019
+ handleEl.removeEventListener("mousedown", stopProp);
3020
+ handleEl.removeEventListener("touchstart", stopProp);
3021
+ });
3022
+ const cleanup = createDragHandler({
3023
+ element: handleEl,
3024
+ svg,
3025
+ onMove: (dx, dy) => {
3026
+ handleEl.setAttribute("cx", String(origCx + dx));
3027
+ handleEl.setAttribute("cy", String(origCy + dy));
3028
+ if (connectorLine) {
3029
+ if (ep.name === "from") {
3030
+ connectorLine.setAttribute("x1", String(origCx + dx));
3031
+ connectorLine.setAttribute("y1", String(origCy + dy));
3032
+ } else {
3033
+ connectorLine.setAttribute("x2", String(origCx + dx));
3034
+ connectorLine.setAttribute("y2", String(origCy + dy));
3035
+ }
3036
+ }
3037
+ },
3038
+ onEnd: (dx, dy, moved) => {
3039
+ handleEl.setAttribute("cx", String(origCx));
3040
+ handleEl.setAttribute("cy", String(origCy));
3041
+ if (connectorLine) {
3042
+ if (ep.name === "from") {
3043
+ connectorLine.setAttribute("x1", String(origCx));
3044
+ connectorLine.setAttribute("y1", String(origCy));
3045
+ } else {
3046
+ connectorLine.setAttribute("x2", String(origCx));
3047
+ connectorLine.setAttribute("y2", String(origCy));
3048
+ }
3049
+ }
3050
+ if (moved) {
3051
+ const existingOffset = textAnnotation.connectorOffset?.[ep.name];
3052
+ const origEndDx = existingOffset?.dx ?? 0;
3053
+ const origEndDy = existingOffset?.dy ?? 0;
3054
+ onEdit({
3055
+ type: "annotation-connector",
3056
+ annotation: textAnnotation,
3057
+ endpoint: ep.name,
3058
+ offset: { dx: origEndDx + dx, dy: origEndDy + dy }
3059
+ });
3060
+ }
3061
+ },
3062
+ setDragging
3063
+ });
3064
+ cleanups.push(cleanup);
3065
+ }
3066
+ const showHandles = () => {
3067
+ for (const h of createdHandles) {
3068
+ h.setAttribute("opacity", "0.6");
3069
+ }
3070
+ };
3071
+ const hideHandles = () => {
3072
+ for (const h of createdHandles) {
3073
+ h.setAttribute("opacity", "0");
3074
+ }
3075
+ };
3076
+ annotationG.addEventListener("mouseenter", showHandles);
3077
+ annotationG.addEventListener("mouseleave", hideHandles);
3078
+ cleanups.push(() => {
3079
+ annotationG.removeEventListener("mouseenter", showHandles);
3080
+ annotationG.removeEventListener("mouseleave", hideHandles);
3081
+ for (const h of createdHandles) {
3082
+ h.remove();
3083
+ }
3084
+ });
3085
+ }
3086
+ return () => {
3087
+ for (const cleanup of cleanups) {
3088
+ cleanup();
3089
+ }
3090
+ };
3091
+ }
3092
+ function wireAnnotationLabelDrag(svg, specAnnotations, onEdit, setDragging) {
3093
+ const cleanups = [];
3094
+ const selectors = [
3095
+ ".viz-annotation-range .viz-annotation-label",
3096
+ ".viz-annotation-refline .viz-annotation-label"
3097
+ ];
3098
+ for (const selector of selectors) {
3099
+ const labels = svg.querySelectorAll(selector);
3100
+ for (const label of labels) {
3101
+ const annotationG = label.closest(".viz-annotation");
3102
+ if (!annotationG) continue;
3103
+ const indexStr = annotationG.getAttribute("data-annotation-index");
3104
+ if (indexStr === null) continue;
3105
+ const index = Number(indexStr);
3106
+ const specAnnotation = specAnnotations[index];
3107
+ if (!specAnnotation) continue;
3108
+ const labelEl = label;
3109
+ labelEl.style.cursor = "grab";
3110
+ const isRange = specAnnotation.type === "range";
3111
+ const existingLabelOffset = isRange ? specAnnotation.labelOffset : specAnnotation.labelOffset;
3112
+ const origLabelDx = existingLabelOffset?.dx ?? 0;
3113
+ const origLabelDy = existingLabelOffset?.dy ?? 0;
3114
+ const cleanup = createDragHandler({
3115
+ element: labelEl,
3116
+ svg,
3117
+ onMove: (dx, dy) => {
3118
+ labelEl.style.transform = `translate(${dx}px, ${dy}px)`;
3119
+ },
3120
+ onEnd: (dx, dy, moved) => {
3121
+ labelEl.style.transform = "";
3122
+ if (moved) {
3123
+ if (isRange) {
3124
+ onEdit({
3125
+ type: "range-label",
3126
+ annotation: specAnnotation,
3127
+ labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy }
3128
+ });
3129
+ } else {
3130
+ onEdit({
3131
+ type: "refline-label",
3132
+ annotation: specAnnotation,
3133
+ labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy }
3134
+ });
3135
+ }
3136
+ }
3137
+ },
3138
+ setDragging
3139
+ });
3140
+ cleanups.push(cleanup);
3141
+ }
3142
+ }
3143
+ return () => {
3144
+ for (const cleanup of cleanups) {
3145
+ cleanup();
3146
+ }
3147
+ };
3148
+ }
3149
+ function wireChromeDrag(svg, spec, onEdit, setDragging) {
3150
+ const chromeTexts = svg.querySelectorAll(".viz-chrome text[data-chrome-key]");
3151
+ const cleanups = [];
3152
+ const chromeConfig = "chrome" in spec ? spec.chrome : void 0;
3153
+ for (const el of chromeTexts) {
3154
+ const textEl = el;
3155
+ const key = textEl.getAttribute("data-chrome-key");
3156
+ if (!key) continue;
3157
+ const chromeEntry = chromeConfig?.[key];
3158
+ const existingOffset = typeof chromeEntry === "object" && chromeEntry !== null ? chromeEntry.offset : void 0;
3159
+ const origChromeDx = existingOffset?.dx ?? 0;
3160
+ const origChromeDy = existingOffset?.dy ?? 0;
3161
+ textEl.style.cursor = "grab";
3162
+ const cleanup = createDragHandler({
3163
+ element: textEl,
3164
+ svg,
3165
+ onMove: (dx, dy) => {
3166
+ textEl.style.transform = `translate(${dx}px, ${dy}px)`;
3167
+ },
3168
+ onEnd: (dx, dy, moved) => {
3169
+ textEl.style.transform = "";
3170
+ if (moved) {
3171
+ onEdit({
3172
+ type: "chrome",
3173
+ key,
3174
+ text: textEl.textContent ?? "",
3175
+ offset: { dx: origChromeDx + dx, dy: origChromeDy + dy }
3176
+ });
3177
+ }
3178
+ },
3179
+ setDragging
3180
+ });
3181
+ cleanups.push(cleanup);
3182
+ }
3183
+ return () => {
3184
+ for (const cleanup of cleanups) {
3185
+ cleanup();
3186
+ }
3187
+ };
3188
+ }
3189
+ function wireLegendDrag(svg, spec, onEdit, setDragging) {
3190
+ const legendG = svg.querySelector(".viz-legend");
3191
+ if (!legendG) return () => {
3192
+ };
3193
+ const cleanups = [];
3194
+ const legendConfig = "legend" in spec ? spec.legend : void 0;
3195
+ const origLegendDx = legendConfig?.offset?.dx ?? 0;
3196
+ const origLegendDy = legendConfig?.offset?.dy ?? 0;
3197
+ legendG.style.cursor = "grab";
3198
+ const cleanup = createDragHandler({
3199
+ element: legendG,
3200
+ svg,
3201
+ onMove: (dx, dy) => {
3202
+ legendG.style.transform = `translate(${dx}px, ${dy}px)`;
3203
+ },
3204
+ onEnd: (dx, dy, moved) => {
3205
+ legendG.style.transform = "";
3206
+ if (moved) {
3207
+ onEdit({ type: "legend", offset: { dx: origLegendDx + dx, dy: origLegendDy + dy } });
3208
+ }
3209
+ },
3210
+ setDragging
3211
+ });
3212
+ cleanups.push(cleanup);
3213
+ return () => {
3214
+ for (const cleanup2 of cleanups) {
3215
+ cleanup2();
3216
+ }
3217
+ };
3218
+ }
3219
+ function wireSeriesLabelDrag(svg, spec, onEdit, setDragging) {
3220
+ const labels = svg.querySelectorAll(".viz-mark-label");
3221
+ const cleanups = [];
3222
+ const labelsConfig = "labels" in spec ? spec.labels : void 0;
3223
+ for (const label of labels) {
3224
+ const labelEl = label;
3225
+ const series = labelEl.getAttribute("data-series") ?? labelEl.closest("[data-series]")?.getAttribute("data-series");
3226
+ if (!series) continue;
3227
+ const existingSeriesOffset = labelsConfig?.offsets?.[series];
3228
+ const origSeriesDx = existingSeriesOffset?.dx ?? 0;
3229
+ const origSeriesDy = existingSeriesOffset?.dy ?? 0;
3230
+ labelEl.style.cursor = "grab";
3231
+ const cleanup = createDragHandler({
3232
+ element: labelEl,
3233
+ svg,
3234
+ onMove: (dx, dy) => {
3235
+ labelEl.style.transform = `translate(${dx}px, ${dy}px)`;
3236
+ },
3237
+ onEnd: (dx, dy, moved) => {
3238
+ labelEl.style.transform = "";
3239
+ if (moved) {
3240
+ onEdit({
3241
+ type: "series-label",
3242
+ series,
3243
+ offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy }
3244
+ });
3245
+ }
3246
+ },
3247
+ setDragging
3248
+ });
3249
+ cleanups.push(cleanup);
3250
+ }
3251
+ return () => {
3252
+ for (const cleanup of cleanups) {
3253
+ cleanup();
3254
+ }
3255
+ };
3256
+ }
3257
+ function wireLegendInteraction(svg, _layout, onLegendToggle) {
3258
+ const legendEntries = svg.querySelectorAll("[data-legend-index]");
3259
+ const cleanups = [];
3260
+ const hiddenSeries = /* @__PURE__ */ new Set();
3261
+ for (const entry of legendEntries) {
3262
+ const handleClick = () => {
3263
+ const label = entry.getAttribute("data-legend-label");
3264
+ if (!label) return;
3265
+ if (hiddenSeries.has(label)) {
3266
+ hiddenSeries.delete(label);
3267
+ entry.setAttribute("opacity", "1");
3268
+ entry.setAttribute("aria-label", `${label}: visible`);
3269
+ onLegendToggle?.(label, true);
3270
+ } else {
3271
+ hiddenSeries.add(label);
3272
+ entry.setAttribute("opacity", "0.3");
3273
+ entry.setAttribute("aria-label", `${label}: hidden`);
3274
+ onLegendToggle?.(label, false);
3275
+ }
3276
+ const marks = svg.querySelectorAll(".viz-mark");
3277
+ for (const mark of marks) {
3278
+ const seriesName = mark.getAttribute("data-series");
3279
+ if (!seriesName) continue;
3280
+ if (hiddenSeries.has(seriesName)) {
3281
+ mark.style.display = "none";
3282
+ } else {
3283
+ mark.style.display = "";
3284
+ }
3285
+ }
3286
+ };
3287
+ entry.addEventListener("click", handleClick);
3288
+ cleanups.push(() => entry.removeEventListener("click", handleClick));
3289
+ }
3290
+ return () => {
3291
+ for (const cleanup of cleanups) {
3292
+ cleanup();
3293
+ }
3294
+ };
3295
+ }
3296
+ function wireKeyboardNav(svg, container, tooltipDescriptors, tooltipManager, layout) {
3297
+ container.setAttribute("tabindex", "0");
3298
+ container.setAttribute("aria-roledescription", "chart");
3299
+ container.setAttribute("aria-label", layout.a11y.altText);
3300
+ const markElements = [];
3301
+ const allMarkEls = svg.querySelectorAll("[data-mark-id]");
3302
+ for (const el of allMarkEls) {
3303
+ const markId = el.getAttribute("data-mark-id");
3304
+ if (markId && tooltipDescriptors.has(markId)) {
3305
+ markElements.push(el);
3306
+ }
3307
+ }
3308
+ let focusIndex = -1;
3309
+ function highlightMark(index) {
3310
+ if (focusIndex >= 0 && focusIndex < markElements.length) {
3311
+ markElements[focusIndex].classList.remove("viz-mark-focused");
3312
+ markElements[focusIndex].removeAttribute("aria-selected");
3313
+ }
3314
+ focusIndex = index;
3315
+ if (focusIndex >= 0 && focusIndex < markElements.length) {
3316
+ const el = markElements[focusIndex];
3317
+ el.classList.add("viz-mark-focused");
3318
+ el.setAttribute("aria-selected", "true");
3319
+ }
3320
+ }
3321
+ function showTooltipForFocused() {
3322
+ if (focusIndex < 0 || focusIndex >= markElements.length) return;
3323
+ const el = markElements[focusIndex];
3324
+ const markId = el.getAttribute("data-mark-id");
3325
+ if (!markId) return;
3326
+ const content = tooltipDescriptors.get(markId);
3327
+ if (!content) return;
3328
+ const bbox = el.getBoundingClientRect();
3329
+ const containerRect = container.getBoundingClientRect();
3330
+ const x = bbox.left + bbox.width / 2 - containerRect.left;
3331
+ const y = bbox.top - containerRect.top;
3332
+ tooltipManager.show(content, x, y);
3333
+ }
3334
+ const handleKeyDown = (e) => {
3335
+ if (markElements.length === 0) return;
3336
+ switch (e.key) {
3337
+ case "ArrowRight":
3338
+ case "ArrowDown": {
3339
+ e.preventDefault();
3340
+ const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
3341
+ highlightMark(next);
3342
+ showTooltipForFocused();
3343
+ break;
3344
+ }
3345
+ case "ArrowLeft":
3346
+ case "ArrowUp": {
3347
+ e.preventDefault();
3348
+ const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
3349
+ highlightMark(prev);
3350
+ showTooltipForFocused();
3351
+ break;
3352
+ }
3353
+ case "Enter":
3354
+ case " ": {
3355
+ e.preventDefault();
3356
+ if (focusIndex >= 0) {
3357
+ showTooltipForFocused();
3358
+ } else if (markElements.length > 0) {
3359
+ highlightMark(0);
3360
+ showTooltipForFocused();
3361
+ }
3362
+ break;
3363
+ }
3364
+ case "Escape": {
3365
+ e.preventDefault();
3366
+ tooltipManager.hide();
3367
+ highlightMark(-1);
3368
+ break;
3369
+ }
3370
+ }
3371
+ };
3372
+ container.addEventListener("keydown", handleKeyDown);
3373
+ return () => {
3374
+ container.removeEventListener("keydown", handleKeyDown);
3375
+ container.removeAttribute("tabindex");
3376
+ container.removeAttribute("aria-roledescription");
3377
+ container.removeAttribute("aria-label");
3378
+ };
3379
+ }
3380
+ function createScreenReaderTable(layout, container) {
3381
+ const data = layout.a11y.dataTableFallback;
3382
+ if (!data || data.length === 0) return null;
3383
+ const table = document.createElement("table");
3384
+ table.className = "viz-sr-only";
3385
+ table.setAttribute("role", "table");
3386
+ table.setAttribute("aria-label", `Data table: ${layout.a11y.altText}`);
3387
+ if (data.length > 0) {
3388
+ const thead = document.createElement("thead");
3389
+ const headerRow = document.createElement("tr");
3390
+ const headers = data[0];
3391
+ for (const header of headers) {
3392
+ const th = document.createElement("th");
3393
+ th.textContent = String(header ?? "");
3394
+ th.setAttribute("scope", "col");
3395
+ headerRow.appendChild(th);
3396
+ }
3397
+ thead.appendChild(headerRow);
3398
+ table.appendChild(thead);
3399
+ }
3400
+ if (data.length > 1) {
3401
+ const tbody = document.createElement("tbody");
3402
+ for (let i = 1; i < data.length; i++) {
3403
+ const tr = document.createElement("tr");
3404
+ const cells = data[i];
3405
+ for (const cell of cells) {
3406
+ const td = document.createElement("td");
3407
+ td.textContent = String(cell ?? "");
3408
+ tr.appendChild(td);
3409
+ }
3410
+ tbody.appendChild(tr);
3411
+ }
3412
+ table.appendChild(tbody);
3413
+ }
3414
+ container.appendChild(table);
3415
+ return table;
3416
+ }
3417
+ function createChart(container, spec, options) {
3418
+ let currentSpec = spec;
3419
+ let currentLayout;
3420
+ let svgElement = null;
3421
+ let tooltipManager = null;
3422
+ let disconnectResize = null;
3423
+ let cleanupTooltipEvents = null;
3424
+ let cleanupKeyboardNav = null;
3425
+ let cleanupLegend = null;
3426
+ let cleanupChartEvents = null;
3427
+ let cleanupAnnotationDrag = null;
3428
+ let cleanupEditDrags = null;
3429
+ let srTable = null;
3430
+ let destroyed = false;
3431
+ let isDragging = false;
3432
+ let pendingRender = false;
3433
+ const measureText = createMeasureText();
3434
+ function compile() {
3435
+ const { width, height } = getContainerDimensions();
3436
+ const darkMode = resolveDarkMode2(options?.darkMode);
3437
+ const compileOpts = {
3438
+ width,
3439
+ height,
3440
+ theme: options?.theme,
3441
+ darkMode,
3442
+ measureText
3443
+ };
3444
+ return compileChart(currentSpec, compileOpts);
3445
+ }
3446
+ function getContainerDimensions() {
3447
+ const rect = container.getBoundingClientRect();
3448
+ return {
3449
+ width: Math.max(rect.width || 600, 100),
3450
+ height: Math.max(rect.height || 400, 100)
3451
+ };
3452
+ }
3453
+ function render() {
3454
+ if (isDragging) {
3455
+ pendingRender = true;
3456
+ return;
3457
+ }
3458
+ if (cleanupTooltipEvents) {
3459
+ cleanupTooltipEvents();
3460
+ cleanupTooltipEvents = null;
3461
+ }
3462
+ if (cleanupKeyboardNav) {
3463
+ cleanupKeyboardNav();
3464
+ cleanupKeyboardNav = null;
3465
+ }
3466
+ if (cleanupLegend) {
3467
+ cleanupLegend();
3468
+ cleanupLegend = null;
3469
+ }
3470
+ if (cleanupChartEvents) {
3471
+ cleanupChartEvents();
3472
+ cleanupChartEvents = null;
3473
+ }
3474
+ if (cleanupAnnotationDrag) {
3475
+ cleanupAnnotationDrag();
3476
+ cleanupAnnotationDrag = null;
3477
+ }
3478
+ if (cleanupEditDrags) {
3479
+ cleanupEditDrags();
3480
+ cleanupEditDrags = null;
3481
+ }
3482
+ if (svgElement?.parentNode) {
3483
+ svgElement.parentNode.removeChild(svgElement);
3484
+ }
3485
+ if (tooltipManager) {
3486
+ tooltipManager.destroy();
3487
+ }
3488
+ if (srTable?.parentNode) {
3489
+ srTable.parentNode.removeChild(srTable);
3490
+ srTable = null;
3491
+ }
3492
+ currentLayout = compile();
3493
+ svgElement = renderChartSVG(currentLayout, container);
3494
+ tooltipManager = createTooltipManager(container);
3495
+ cleanupTooltipEvents = wireTooltipEvents(
3496
+ svgElement,
3497
+ currentLayout.tooltipDescriptors,
3498
+ tooltipManager
3499
+ );
3500
+ cleanupKeyboardNav = wireKeyboardNav(
3501
+ svgElement,
3502
+ container,
3503
+ currentLayout.tooltipDescriptors,
3504
+ tooltipManager,
3505
+ currentLayout
3506
+ );
3507
+ cleanupLegend = wireLegendInteraction(svgElement, currentLayout, options?.onLegendToggle);
3508
+ if (options?.onMarkClick || options?.onMarkHover || options?.onMarkLeave || options?.onAnnotationClick) {
3509
+ const specAnnotations = "annotations" in currentSpec && Array.isArray(currentSpec.annotations) ? currentSpec.annotations : [];
3510
+ cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
3511
+ }
3512
+ const setDragging = (dragging) => {
3513
+ isDragging = dragging;
3514
+ if (!dragging && pendingRender) {
3515
+ pendingRender = false;
3516
+ render();
3517
+ }
3518
+ };
3519
+ const dragAnnotations = "annotations" in currentSpec && Array.isArray(currentSpec.annotations) ? currentSpec.annotations : [];
3520
+ if (options?.onAnnotationEdit || options?.onEdit) {
3521
+ cleanupAnnotationDrag = wireAnnotationDrag(
3522
+ svgElement,
3523
+ dragAnnotations,
3524
+ options?.onAnnotationEdit,
3525
+ options?.onEdit,
3526
+ setDragging
3527
+ );
3528
+ }
3529
+ if (options?.onEdit) {
3530
+ const editCleanups = [];
3531
+ editCleanups.push(
3532
+ wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging)
3533
+ );
3534
+ editCleanups.push(
3535
+ wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging)
3536
+ );
3537
+ editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
3538
+ editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
3539
+ editCleanups.push(wireSeriesLabelDrag(svgElement, currentSpec, options.onEdit, setDragging));
3540
+ cleanupEditDrags = () => {
3541
+ for (const cleanup of editCleanups) {
3542
+ cleanup();
3543
+ }
3544
+ };
3545
+ }
3546
+ srTable = createScreenReaderTable(currentLayout, container);
3547
+ container.classList.add("viz-root");
3548
+ const isDark = resolveDarkMode2(options?.darkMode);
3549
+ if (isDark) {
3550
+ container.classList.add("viz-dark");
3551
+ } else {
3552
+ container.classList.remove("viz-dark");
3553
+ }
3554
+ }
3555
+ function update(newSpec) {
3556
+ if (destroyed) return;
3557
+ currentSpec = newSpec;
3558
+ render();
3559
+ }
3560
+ function resize() {
3561
+ if (destroyed) return;
3562
+ render();
3563
+ }
3564
+ function doExport(format, exportOptions) {
3565
+ if (!svgElement) {
3566
+ throw new Error("Chart is not rendered yet");
3567
+ }
3568
+ switch (format) {
3569
+ case "svg":
3570
+ return exportSVG(svgElement);
3571
+ case "png":
3572
+ return exportPNG(svgElement, exportOptions);
3573
+ case "csv":
3574
+ return exportCSV(
3575
+ "data" in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : []
3576
+ );
3577
+ default:
3578
+ throw new Error(`Unsupported export format: ${format}`);
3579
+ }
3580
+ }
3581
+ function destroy() {
3582
+ if (destroyed) return;
3583
+ destroyed = true;
3584
+ if (cleanupTooltipEvents) {
3585
+ cleanupTooltipEvents();
3586
+ cleanupTooltipEvents = null;
3587
+ }
3588
+ if (cleanupKeyboardNav) {
3589
+ cleanupKeyboardNav();
3590
+ cleanupKeyboardNav = null;
3591
+ }
3592
+ if (cleanupLegend) {
3593
+ cleanupLegend();
3594
+ cleanupLegend = null;
3595
+ }
3596
+ if (cleanupChartEvents) {
3597
+ cleanupChartEvents();
3598
+ cleanupChartEvents = null;
3599
+ }
3600
+ if (cleanupAnnotationDrag) {
3601
+ cleanupAnnotationDrag();
3602
+ cleanupAnnotationDrag = null;
3603
+ }
3604
+ if (cleanupEditDrags) {
3605
+ cleanupEditDrags();
3606
+ cleanupEditDrags = null;
3607
+ }
3608
+ if (disconnectResize) {
3609
+ disconnectResize();
3610
+ disconnectResize = null;
3611
+ }
3612
+ if (tooltipManager) {
3613
+ tooltipManager.destroy();
3614
+ tooltipManager = null;
3615
+ }
3616
+ if (svgElement?.parentNode) {
3617
+ svgElement.parentNode.removeChild(svgElement);
3618
+ svgElement = null;
3619
+ }
3620
+ if (srTable?.parentNode) {
3621
+ srTable.parentNode.removeChild(srTable);
3622
+ srTable = null;
3623
+ }
3624
+ container.classList.remove("viz-dark");
3625
+ container.classList.remove("viz-root");
3626
+ }
3627
+ render();
3628
+ if (options?.responsive !== false) {
3629
+ disconnectResize = observeResize(container, () => {
3630
+ resize();
3631
+ });
3632
+ }
3633
+ return {
3634
+ update,
3635
+ resize,
3636
+ export: doExport,
3637
+ destroy,
3638
+ get layout() {
3639
+ return currentLayout;
3640
+ }
3641
+ };
3642
+ }
3643
+
3644
+ // src/renderers/table-cells.ts
3645
+ function applyCellStyle(td, cell) {
3646
+ if (cell.style.backgroundColor) {
3647
+ td.style.background = cell.style.backgroundColor;
3648
+ }
3649
+ if (cell.style.color) {
3650
+ td.style.color = cell.style.color;
3651
+ }
3652
+ if (cell.style.fontWeight) {
3653
+ td.style.fontWeight = String(cell.style.fontWeight);
3654
+ }
3655
+ if (cell.style.fontVariant) {
3656
+ td.style.fontVariant = cell.style.fontVariant;
3657
+ }
3658
+ if (cell.aria) {
3659
+ td.setAttribute("aria-label", cell.aria);
3660
+ }
3661
+ }
3662
+ function countryToEmoji(code) {
3663
+ return [...code.toUpperCase()].map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397)).join("");
3664
+ }
3665
+ var COUNTRY_NAMES = {
3666
+ US: "United States",
3667
+ CN: "China",
3668
+ IN: "India",
3669
+ ID: "Indonesia",
3670
+ BR: "Brazil",
3671
+ PK: "Pakistan",
3672
+ NG: "Nigeria",
3673
+ BD: "Bangladesh",
3674
+ RU: "Russia",
3675
+ MX: "Mexico",
3676
+ JP: "Japan",
3677
+ DE: "Germany",
3678
+ GB: "United Kingdom",
3679
+ FR: "France",
3680
+ IT: "Italy",
3681
+ CA: "Canada",
3682
+ AU: "Australia",
3683
+ KR: "South Korea",
3684
+ ES: "Spain",
3685
+ AR: "Argentina",
3686
+ CO: "Colombia",
3687
+ ZA: "South Africa",
3688
+ TR: "Turkey",
3689
+ SA: "Saudi Arabia",
3690
+ UA: "Ukraine",
3691
+ PL: "Poland",
3692
+ NL: "Netherlands",
3693
+ SE: "Sweden",
3694
+ NO: "Norway",
3695
+ DK: "Denmark",
3696
+ FI: "Finland",
3697
+ CH: "Switzerland",
3698
+ AT: "Austria",
3699
+ BE: "Belgium",
3700
+ PT: "Portugal",
3701
+ IE: "Ireland",
3702
+ NZ: "New Zealand",
3703
+ SG: "Singapore",
3704
+ IL: "Israel",
3705
+ AE: "United Arab Emirates",
3706
+ EG: "Egypt",
3707
+ TH: "Thailand",
3708
+ VN: "Vietnam",
3709
+ PH: "Philippines",
3710
+ MY: "Malaysia",
3711
+ CL: "Chile",
3712
+ PE: "Peru",
3713
+ CZ: "Czech Republic",
3714
+ GR: "Greece",
3715
+ HU: "Hungary",
3716
+ RO: "Romania",
3717
+ ET: "Ethiopia"
3718
+ };
3719
+ function getCountryName(code) {
3720
+ return COUNTRY_NAMES[code.toUpperCase()] || code.toUpperCase();
3721
+ }
3722
+ function describeSparklineTrend(data) {
3723
+ if (data.type === "line" && data.points.length >= 2) {
3724
+ const first = data.points[0].y;
3725
+ const last = data.points[data.points.length - 1].y;
3726
+ const count = data.points.length;
3727
+ if (last > first) return `Sparkline with ${count} points, trending upward`;
3728
+ if (last < first) return `Sparkline with ${count} points, trending downward`;
3729
+ return `Sparkline with ${count} points, roughly flat`;
3730
+ }
3731
+ if ((data.type === "bar" || data.type === "column") && data.bars.length > 0) {
3732
+ return `${data.type === "column" ? "Column" : "Bar"} sparkline with ${data.bars.length} values`;
3733
+ }
3734
+ return "Sparkline";
3735
+ }
3736
+ function renderTextCell(cell) {
3737
+ const td = document.createElement("td");
3738
+ td.textContent = cell.formattedValue;
3739
+ applyCellStyle(td, cell);
3740
+ return td;
3741
+ }
3742
+ function renderHeatmapCell(cell) {
3743
+ const td = document.createElement("td");
3744
+ td.textContent = cell.formattedValue;
3745
+ applyCellStyle(td, cell);
3746
+ return td;
3747
+ }
3748
+ function renderCategoryCell(cell) {
3749
+ const td = document.createElement("td");
3750
+ td.textContent = cell.formattedValue;
3751
+ applyCellStyle(td, cell);
3752
+ return td;
3753
+ }
3754
+ function renderBarCell(cell) {
3755
+ const td = document.createElement("td");
3756
+ td.className = "viz-table-bar";
3757
+ applyCellStyle(td, cell);
3758
+ const fill = document.createElement("div");
3759
+ fill.className = "viz-table-bar-fill";
3760
+ fill.style.width = `${Math.round(cell.barWidth * 100)}%`;
3761
+ fill.style.left = `${Math.round(cell.barOffset * 100)}%`;
3762
+ fill.style.background = cell.barColor;
3763
+ td.appendChild(fill);
3764
+ const valueSpan = document.createElement("span");
3765
+ valueSpan.className = "viz-table-bar-value";
3766
+ valueSpan.textContent = cell.formattedValue;
3767
+ td.appendChild(valueSpan);
3768
+ return td;
3769
+ }
3770
+ function formatSparklineValue(v) {
3771
+ if (Math.abs(v) >= 1e3) {
3772
+ return v.toLocaleString("en-US", { maximumFractionDigits: 0 });
3773
+ }
3774
+ if (Math.abs(v) >= 100) {
3775
+ return v.toFixed(0);
3776
+ }
3777
+ return v.toFixed(1);
3778
+ }
3779
+ function renderSparklineCell(cell) {
3780
+ const td = document.createElement("td");
3781
+ applyCellStyle(td, cell);
3782
+ const sparklineData = cell.sparklineData;
3783
+ if (!sparklineData || sparklineData.count === 0) {
3784
+ td.textContent = cell.formattedValue || "";
3785
+ return td;
3786
+ }
3787
+ const trendDescription = describeSparklineTrend(sparklineData);
3788
+ if (!td.getAttribute("aria-label")) {
3789
+ td.setAttribute("aria-label", trendDescription);
3790
+ }
3791
+ const wrapper = document.createElement("span");
3792
+ wrapper.className = "viz-table-sparkline";
3793
+ const svgNS = "http://www.w3.org/2000/svg";
3794
+ if (sparklineData.type === "line") {
3795
+ const svgH = 28;
3796
+ const padY = 4;
3797
+ const lineH = svgH - padY * 2;
3798
+ const svg = document.createElementNS(svgNS, "svg");
3799
+ svg.setAttribute("aria-hidden", "true");
3800
+ svg.setAttribute("xmlns", svgNS);
3801
+ svg.style.height = `${svgH}px`;
3802
+ const yPositions = sparklineData.points.map((p) => padY + (1 - p.y) * lineH);
3803
+ const viewW = 1e3;
3804
+ svg.setAttribute("viewBox", `0 0 ${viewW} ${svgH}`);
3805
+ svg.setAttribute("preserveAspectRatio", "none");
3806
+ const ptsScaled = sparklineData.points.map((p, i) => ({
3807
+ x: p.x * viewW,
3808
+ y: yPositions[i]
3809
+ }));
3810
+ const scaledPointsStr = ptsScaled.map((p) => `${p.x},${p.y}`).join(" ");
3811
+ const polyline = document.createElementNS(svgNS, "polyline");
3812
+ polyline.setAttribute("points", scaledPointsStr);
3813
+ polyline.setAttribute("fill", "none");
3814
+ polyline.setAttribute("stroke", sparklineData.color);
3815
+ polyline.setAttribute("stroke-width", "1.5");
3816
+ polyline.setAttribute("stroke-linejoin", "round");
3817
+ polyline.setAttribute("vector-effect", "non-scaling-stroke");
3818
+ svg.appendChild(polyline);
3819
+ wrapper.appendChild(svg);
3820
+ const firstY = yPositions[0];
3821
+ const lastY = yPositions[yPositions.length - 1];
3822
+ const dotSize = 5;
3823
+ const startDot = document.createElement("span");
3824
+ startDot.className = "viz-table-sparkline-dot";
3825
+ startDot.style.left = "0";
3826
+ startDot.style.top = `${firstY - dotSize / 2}px`;
3827
+ startDot.style.background = sparklineData.color;
3828
+ wrapper.appendChild(startDot);
3829
+ const endDot = document.createElement("span");
3830
+ endDot.className = "viz-table-sparkline-dot";
3831
+ endDot.style.right = "0";
3832
+ endDot.style.top = `${lastY - dotSize / 2}px`;
3833
+ endDot.style.background = sparklineData.color;
3834
+ wrapper.appendChild(endDot);
3835
+ const labelsRow = document.createElement("span");
3836
+ labelsRow.className = "viz-table-sparkline-labels";
3837
+ labelsRow.style.color = sparklineData.color;
3838
+ const startLabel = document.createElement("span");
3839
+ startLabel.textContent = formatSparklineValue(sparklineData.startValue);
3840
+ labelsRow.appendChild(startLabel);
3841
+ const endLabel = document.createElement("span");
3842
+ endLabel.textContent = formatSparklineValue(sparklineData.endValue);
3843
+ labelsRow.appendChild(endLabel);
3844
+ wrapper.appendChild(labelsRow);
3845
+ } else if (sparklineData.type === "column") {
3846
+ const width = 80;
3847
+ const height = 20;
3848
+ const padding = 2;
3849
+ const innerW = width - padding * 2;
3850
+ const innerH = height - padding * 2;
3851
+ const svg = document.createElementNS(svgNS, "svg");
3852
+ svg.setAttribute("width", String(width));
3853
+ svg.setAttribute("height", String(height));
3854
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
3855
+ svg.setAttribute("aria-hidden", "true");
3856
+ const barCount = sparklineData.bars.length;
3857
+ if (barCount > 0) {
3858
+ const gap = 1;
3859
+ const barW = Math.max(1, (innerW - gap * (barCount - 1)) / barCount);
3860
+ for (let i = 0; i < barCount; i++) {
3861
+ const barH = Math.max(1, sparklineData.bars[i] * innerH);
3862
+ const x = padding + i * (barW + gap);
3863
+ const y = padding + innerH - barH;
3864
+ const rect = document.createElementNS(svgNS, "rect");
3865
+ rect.setAttribute("x", String(x));
3866
+ rect.setAttribute("y", String(y));
3867
+ rect.setAttribute("width", String(barW));
3868
+ rect.setAttribute("height", String(barH));
3869
+ rect.setAttribute("rx", "1.5");
3870
+ rect.setAttribute("fill", sparklineData.color);
3871
+ svg.appendChild(rect);
3872
+ }
3873
+ }
3874
+ wrapper.appendChild(svg);
3875
+ } else {
3876
+ const width = 80;
3877
+ const height = 20;
3878
+ const padding = 2;
3879
+ const innerW = width - padding * 2;
3880
+ const innerH = height - padding * 2;
3881
+ const svg = document.createElementNS(svgNS, "svg");
3882
+ svg.setAttribute("width", String(width));
3883
+ svg.setAttribute("height", String(height));
3884
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
3885
+ svg.setAttribute("aria-hidden", "true");
3886
+ const barCount = sparklineData.bars.length;
3887
+ if (barCount > 0) {
3888
+ const gap = 1;
3889
+ const barH = Math.max(1, (innerH - gap * (barCount - 1)) / barCount);
3890
+ for (let i = 0; i < barCount; i++) {
3891
+ const barW = Math.max(1, sparklineData.bars[i] * innerW);
3892
+ const x = padding;
3893
+ const y = padding + i * (barH + gap);
3894
+ const rect = document.createElementNS(svgNS, "rect");
3895
+ rect.setAttribute("x", String(x));
3896
+ rect.setAttribute("y", String(y));
3897
+ rect.setAttribute("width", String(barW));
3898
+ rect.setAttribute("height", String(barH));
3899
+ rect.setAttribute("rx", "1.5");
3900
+ rect.setAttribute("fill", sparklineData.color);
3901
+ svg.appendChild(rect);
3902
+ }
3903
+ }
3904
+ wrapper.appendChild(svg);
3905
+ }
3906
+ td.appendChild(wrapper);
3907
+ return td;
3908
+ }
3909
+ function renderImageCell(cell) {
3910
+ const td = document.createElement("td");
3911
+ applyCellStyle(td, cell);
3912
+ const wrapper = document.createElement("span");
3913
+ wrapper.className = `viz-table-image${cell.rounded ? " viz-table-image-rounded" : ""}`;
3914
+ const img = document.createElement("img");
3915
+ img.src = cell.src;
3916
+ img.alt = cell.formattedValue || "";
3917
+ img.width = cell.imageWidth;
3918
+ img.height = cell.imageHeight;
3919
+ img.loading = "lazy";
3920
+ wrapper.appendChild(img);
3921
+ td.appendChild(wrapper);
3922
+ return td;
3923
+ }
3924
+ function renderFlagCell(cell) {
3925
+ const td = document.createElement("td");
3926
+ applyCellStyle(td, cell);
3927
+ const span = document.createElement("span");
3928
+ span.className = "viz-table-flag";
3929
+ span.setAttribute("role", "img");
3930
+ if (cell.countryCode && cell.countryCode.length === 2) {
3931
+ const countryName = getCountryName(cell.countryCode);
3932
+ span.textContent = countryToEmoji(cell.countryCode);
3933
+ span.setAttribute("aria-label", `Flag: ${countryName}`);
3934
+ } else {
3935
+ span.textContent = cell.formattedValue;
3936
+ span.setAttribute("aria-label", cell.formattedValue);
3937
+ }
3938
+ td.appendChild(span);
3939
+ return td;
3940
+ }
3941
+ function renderCell(cell) {
3942
+ switch (cell.cellType) {
3943
+ case "text":
3944
+ return renderTextCell(cell);
3945
+ case "heatmap":
3946
+ return renderHeatmapCell(cell);
3947
+ case "category":
3948
+ return renderCategoryCell(cell);
3949
+ case "bar":
3950
+ return renderBarCell(cell);
3951
+ case "sparkline":
3952
+ return renderSparklineCell(cell);
3953
+ case "image":
3954
+ return renderImageCell(cell);
3955
+ case "flag":
3956
+ return renderFlagCell(cell);
3957
+ default:
3958
+ return renderTextCell(cell);
3959
+ }
3960
+ }
3961
+
3962
+ // src/table-keyboard.ts
3963
+ function attachKeyboardNav(options) {
3964
+ const { wrapper, onSort, onClearSearch, onAnnounce } = options;
3965
+ let focusedCell = { row: -1, col: 0 };
3966
+ const table = wrapper.querySelector("table");
3967
+ if (!table) return () => {
3968
+ };
3969
+ const tbody = table.querySelector("tbody");
3970
+ const thead = table.querySelector("thead");
3971
+ if (!tbody || !thead) return () => {
3972
+ };
3973
+ tbody.setAttribute("tabindex", "0");
3974
+ function getRows() {
3975
+ if (!tbody) return [];
3976
+ return Array.from(tbody.querySelectorAll("tr"));
3977
+ }
3978
+ function getHeaderCells() {
3979
+ if (!thead) return [];
3980
+ const headerRow = thead.querySelector("tr");
3981
+ if (!headerRow) return [];
3982
+ return Array.from(headerRow.querySelectorAll("th"));
3983
+ }
3984
+ function getCellsInRow(tr) {
3985
+ return Array.from(tr.querySelectorAll("td"));
3986
+ }
3987
+ function getColCount() {
3988
+ const rows = getRows();
3989
+ if (rows.length === 0) return getHeaderCells().length;
3990
+ return getCellsInRow(rows[0]).length;
3991
+ }
3992
+ function clearFocusHighlight() {
3993
+ const prev = wrapper.querySelector(".viz-table-cell-focus");
3994
+ if (prev) {
3995
+ prev.classList.remove("viz-table-cell-focus");
3996
+ prev.removeAttribute("id");
3997
+ }
3998
+ }
3999
+ function setFocusedCell(row, col) {
4000
+ clearFocusHighlight();
4001
+ const rows = getRows();
4002
+ const colCount = getColCount();
4003
+ if (rows.length === 0) return;
4004
+ row = Math.max(0, Math.min(row, rows.length - 1));
4005
+ col = Math.max(0, Math.min(col, colCount - 1));
4006
+ focusedCell = { row, col };
4007
+ const tr = rows[row];
4008
+ if (!tr) return;
4009
+ const cells = getCellsInRow(tr);
4010
+ const cell = cells[col];
4011
+ if (!cell) return;
4012
+ const cellId = `viz-cell-${row}-${col}`;
4013
+ cell.id = cellId;
4014
+ cell.classList.add("viz-table-cell-focus");
4015
+ cell.setAttribute("data-row", String(row));
4016
+ cell.setAttribute("data-col", String(col));
4017
+ if (tbody) {
4018
+ tbody.setAttribute("aria-activedescendant", cellId);
4019
+ }
4020
+ cell.scrollIntoView({ block: "nearest", inline: "nearest" });
4021
+ }
4022
+ function handleTbodyFocus() {
4023
+ if (focusedCell.row < 0) {
4024
+ setFocusedCell(0, 0);
4025
+ } else {
4026
+ setFocusedCell(focusedCell.row, focusedCell.col);
4027
+ }
4028
+ }
4029
+ function handleTbodyKeydown(e) {
4030
+ const rows = getRows();
4031
+ if (rows.length === 0) return;
4032
+ const colCount = getColCount();
4033
+ const { row, col } = focusedCell;
4034
+ switch (e.key) {
4035
+ case "ArrowDown":
4036
+ e.preventDefault();
4037
+ if (row < rows.length - 1) {
4038
+ setFocusedCell(row + 1, col);
4039
+ }
4040
+ break;
4041
+ case "ArrowUp":
4042
+ e.preventDefault();
4043
+ if (row > 0) {
4044
+ setFocusedCell(row - 1, col);
4045
+ } else {
4046
+ focusHeaderCell(col);
4047
+ }
4048
+ break;
4049
+ case "ArrowRight":
4050
+ e.preventDefault();
4051
+ if (col < colCount - 1) {
4052
+ setFocusedCell(row, col + 1);
4053
+ }
4054
+ break;
4055
+ case "ArrowLeft":
4056
+ e.preventDefault();
4057
+ if (col > 0) {
4058
+ setFocusedCell(row, col - 1);
4059
+ }
4060
+ break;
4061
+ case "Home":
4062
+ e.preventDefault();
4063
+ setFocusedCell(row, 0);
4064
+ break;
4065
+ case "End":
4066
+ e.preventDefault();
4067
+ setFocusedCell(row, colCount - 1);
4068
+ break;
4069
+ }
4070
+ }
4071
+ function focusHeaderCell(col) {
4072
+ const headers = getHeaderCells();
4073
+ if (col >= 0 && col < headers.length) {
4074
+ clearFocusHighlight();
4075
+ headers[col].focus();
4076
+ }
4077
+ }
4078
+ function handleHeaderKeydown(e) {
4079
+ const th = e.currentTarget;
4080
+ const headers = getHeaderCells();
4081
+ const colIndex = headers.indexOf(th);
4082
+ if (colIndex < 0) return;
4083
+ switch (e.key) {
4084
+ case "ArrowRight":
4085
+ e.preventDefault();
4086
+ if (colIndex < headers.length - 1) {
4087
+ headers[colIndex + 1].focus();
4088
+ }
4089
+ break;
4090
+ case "ArrowLeft":
4091
+ e.preventDefault();
4092
+ if (colIndex > 0) {
4093
+ headers[colIndex - 1].focus();
4094
+ }
4095
+ break;
4096
+ case "ArrowDown":
4097
+ e.preventDefault();
4098
+ if (tbody) {
4099
+ tbody.focus();
4100
+ setFocusedCell(0, colIndex);
4101
+ }
4102
+ break;
4103
+ case "Enter":
4104
+ case " ": {
4105
+ e.preventDefault();
4106
+ const sortColumn = th.getAttribute("data-column");
4107
+ const sortBtn = th.querySelector("[data-sort-column]");
4108
+ if (sortColumn && sortBtn) {
4109
+ onSort(sortColumn);
4110
+ }
4111
+ break;
4112
+ }
4113
+ }
4114
+ }
4115
+ const searchInput = wrapper.querySelector(".viz-table-search input");
4116
+ function handleSearchKeydown(e) {
4117
+ if (e.key === "Escape") {
4118
+ e.preventDefault();
4119
+ onClearSearch();
4120
+ if (tbody) {
4121
+ tbody.focus();
4122
+ onAnnounce("Search cleared");
4123
+ }
4124
+ }
4125
+ }
4126
+ tbody.addEventListener("focus", handleTbodyFocus);
4127
+ tbody.addEventListener("keydown", handleTbodyKeydown);
4128
+ const headerCells = getHeaderCells();
4129
+ for (const th of headerCells) {
4130
+ th.setAttribute("tabindex", "0");
4131
+ th.addEventListener("keydown", handleHeaderKeydown);
4132
+ }
4133
+ if (searchInput) {
4134
+ searchInput.addEventListener("keydown", handleSearchKeydown);
4135
+ }
4136
+ return () => {
4137
+ tbody.removeEventListener("focus", handleTbodyFocus);
4138
+ tbody.removeEventListener("keydown", handleTbodyKeydown);
4139
+ for (const th of headerCells) {
4140
+ th.removeEventListener("keydown", handleHeaderKeydown);
4141
+ }
4142
+ if (searchInput) {
4143
+ searchInput.removeEventListener("keydown", handleSearchKeydown);
4144
+ }
4145
+ clearFocusHighlight();
4146
+ };
4147
+ }
4148
+
4149
+ // src/table-mount.ts
4150
+ import { getBreakpoint } from "@opendata-ai/openchart-core";
4151
+ import { compileTable } from "@opendata-ai/openchart-engine";
4152
+
4153
+ // src/table-renderer.ts
4154
+ function renderChromeBlock(layout, position) {
4155
+ const chrome = layout.chrome;
4156
+ if (position === "header") {
4157
+ if (!chrome.title && !chrome.subtitle) return null;
4158
+ const div2 = document.createElement("div");
4159
+ div2.className = "viz-chrome";
4160
+ if (chrome.title) {
4161
+ const h = document.createElement("div");
4162
+ h.className = "viz-table-title";
4163
+ h.textContent = chrome.title.text;
4164
+ div2.appendChild(h);
4165
+ }
4166
+ if (chrome.subtitle) {
4167
+ const sub = document.createElement("div");
4168
+ sub.className = "viz-table-subtitle";
4169
+ sub.textContent = chrome.subtitle.text;
4170
+ div2.appendChild(sub);
4171
+ }
4172
+ return div2;
4173
+ }
4174
+ if (!chrome.source && !chrome.footer) return null;
4175
+ const div = document.createElement("div");
4176
+ div.className = "viz-chrome viz-chrome-footer";
4177
+ if (chrome.source) {
4178
+ const src = document.createElement("div");
4179
+ src.className = "viz-table-source";
4180
+ src.textContent = chrome.source.text;
4181
+ div.appendChild(src);
4182
+ }
4183
+ if (chrome.footer) {
4184
+ const foot = document.createElement("div");
4185
+ foot.className = "viz-table-footer-text";
4186
+ foot.textContent = chrome.footer.text;
4187
+ div.appendChild(foot);
4188
+ }
4189
+ return div;
4190
+ }
4191
+ function renderThead(columns, sort) {
4192
+ const thead = document.createElement("thead");
4193
+ const tr = document.createElement("tr");
4194
+ tr.setAttribute("role", "row");
4195
+ for (const col of columns) {
4196
+ const th = document.createElement("th");
4197
+ th.setAttribute("scope", "col");
4198
+ th.setAttribute("role", "columnheader");
4199
+ th.style.textAlign = col.align;
4200
+ th.style.width = `${col.width}px`;
4201
+ let ariaSortValue = "none";
4202
+ if (sort && sort.column === col.key) {
4203
+ ariaSortValue = sort.direction === "asc" ? "ascending" : "descending";
4204
+ }
4205
+ th.setAttribute("aria-sort", ariaSortValue);
4206
+ th.setAttribute("data-column", col.key);
4207
+ const labelSpan = document.createTextNode(col.label);
4208
+ th.appendChild(labelSpan);
4209
+ if (col.sortable) {
4210
+ const btn = document.createElement("button");
4211
+ btn.className = "viz-table-sort-btn";
4212
+ btn.setAttribute("aria-label", `Sort by ${col.label}`);
4213
+ btn.setAttribute("data-sort-column", col.key);
4214
+ btn.type = "button";
4215
+ th.appendChild(btn);
4216
+ }
4217
+ tr.appendChild(th);
4218
+ }
4219
+ thead.appendChild(tr);
4220
+ return thead;
4221
+ }
4222
+ function renderTbody(rows, columns) {
4223
+ const tbody = document.createElement("tbody");
4224
+ for (let r = 0; r < rows.length; r++) {
4225
+ const row = rows[r];
4226
+ const tr = document.createElement("tr");
4227
+ tr.setAttribute("role", "row");
4228
+ tr.setAttribute("data-row-id", row.id);
4229
+ for (let c = 0; c < columns.length; c++) {
4230
+ const cell = row.cells[c];
4231
+ if (!cell) continue;
4232
+ const td = renderCell(cell);
4233
+ td.setAttribute("role", "gridcell");
4234
+ td.style.textAlign = columns[c].align;
4235
+ tr.appendChild(td);
4236
+ }
4237
+ tbody.appendChild(tr);
4238
+ }
4239
+ return tbody;
4240
+ }
4241
+ function renderSearchBar(layout) {
4242
+ if (!layout.search.enabled) return null;
4243
+ const div = document.createElement("div");
4244
+ div.className = "viz-table-search";
4245
+ const input = document.createElement("input");
4246
+ input.type = "search";
4247
+ input.placeholder = layout.search.placeholder;
4248
+ input.setAttribute("aria-label", "Search table");
4249
+ input.value = layout.search.query;
4250
+ div.appendChild(input);
4251
+ return div;
4252
+ }
4253
+ function renderPagination(layout) {
4254
+ if (!layout.pagination) return null;
4255
+ const { page, pageSize, totalRows, totalPages } = layout.pagination;
4256
+ const div = document.createElement("div");
4257
+ div.className = "viz-table-pagination";
4258
+ const info = document.createElement("span");
4259
+ info.className = "viz-table-pagination-info";
4260
+ if (totalRows === 0) {
4261
+ info.textContent = "No results";
4262
+ } else {
4263
+ const start = page * pageSize + 1;
4264
+ const end = Math.min((page + 1) * pageSize, totalRows);
4265
+ info.textContent = `Showing ${start}-${end} of ${totalRows}`;
4266
+ }
4267
+ div.appendChild(info);
4268
+ const btnGroup = document.createElement("span");
4269
+ btnGroup.className = "viz-table-pagination-btns";
4270
+ const prevBtn = document.createElement("button");
4271
+ prevBtn.setAttribute("aria-label", "Previous page");
4272
+ prevBtn.setAttribute("data-page-action", "prev");
4273
+ prevBtn.textContent = "Prev";
4274
+ prevBtn.disabled = page <= 0;
4275
+ btnGroup.appendChild(prevBtn);
4276
+ const nextBtn = document.createElement("button");
4277
+ nextBtn.setAttribute("aria-label", "Next page");
4278
+ nextBtn.setAttribute("data-page-action", "next");
4279
+ nextBtn.textContent = "Next";
4280
+ nextBtn.disabled = page >= totalPages - 1;
4281
+ btnGroup.appendChild(nextBtn);
4282
+ div.appendChild(btnGroup);
4283
+ return div;
4284
+ }
4285
+ function renderEmptyState(message) {
4286
+ const div = document.createElement("div");
4287
+ div.className = "viz-table-empty";
4288
+ div.setAttribute("aria-live", "polite");
4289
+ div.textContent = message;
4290
+ return div;
4291
+ }
4292
+ function renderTable(layout, container) {
4293
+ const wrapper = document.createElement("div");
4294
+ wrapper.className = "viz-table-wrapper";
4295
+ const { theme, chrome } = layout;
4296
+ if (theme) {
4297
+ const s = wrapper.style;
4298
+ s.setProperty("--viz-bg", theme.colors.background);
4299
+ s.setProperty("--viz-text", theme.colors.text);
4300
+ s.setProperty("--viz-text-secondary", theme.colors.axis ?? theme.colors.text);
4301
+ s.setProperty("--viz-text-muted", theme.colors.axis ?? theme.colors.text);
4302
+ s.setProperty("--viz-gridline", theme.colors.gridline);
4303
+ s.setProperty("--viz-border", theme.colors.gridline);
4304
+ s.setProperty("--viz-font-family", theme.fonts.family);
4305
+ s.fontFamily = theme.fonts.family;
4306
+ }
4307
+ {
4308
+ const s = wrapper.style;
4309
+ if (chrome.title) {
4310
+ s.setProperty("--viz-title-computed-size", `${chrome.title.style.fontSize}px`);
4311
+ s.setProperty("--viz-title-computed-weight", String(chrome.title.style.fontWeight));
4312
+ s.setProperty("--viz-title-computed-color", chrome.title.style.fill);
4313
+ }
4314
+ if (chrome.subtitle) {
4315
+ s.setProperty("--viz-subtitle-computed-size", `${chrome.subtitle.style.fontSize}px`);
4316
+ s.setProperty("--viz-subtitle-computed-weight", String(chrome.subtitle.style.fontWeight));
4317
+ s.setProperty("--viz-subtitle-computed-color", chrome.subtitle.style.fill);
4318
+ }
4319
+ if (chrome.source) {
4320
+ s.setProperty("--viz-source-computed-size", `${chrome.source.style.fontSize}px`);
4321
+ s.setProperty("--viz-source-computed-color", chrome.source.style.fill);
4322
+ }
4323
+ if (chrome.footer) {
4324
+ s.setProperty("--viz-footer-computed-size", `${chrome.footer.style.fontSize}px`);
4325
+ s.setProperty("--viz-footer-computed-color", chrome.footer.style.fill);
4326
+ }
4327
+ }
4328
+ if (layout.compact) {
4329
+ wrapper.classList.add("viz-table--compact");
4330
+ }
4331
+ const headerChrome = renderChromeBlock(layout, "header");
4332
+ if (headerChrome) {
4333
+ wrapper.appendChild(headerChrome);
4334
+ }
4335
+ const searchBar = renderSearchBar(layout);
4336
+ if (searchBar) {
4337
+ wrapper.appendChild(searchBar);
4338
+ }
4339
+ if (layout.rows.length === 0) {
4340
+ const message = layout.search.query ? "No results found" : "No data";
4341
+ wrapper.appendChild(renderEmptyState(message));
4342
+ } else {
4343
+ const scroll = document.createElement("div");
4344
+ scroll.className = "viz-table-scroll";
4345
+ const table = document.createElement("table");
4346
+ table.setAttribute("role", "grid");
4347
+ table.setAttribute("aria-label", layout.a11y.caption);
4348
+ if (layout.stickyFirstColumn) {
4349
+ table.classList.add("viz-table--sticky");
4350
+ }
4351
+ const caption = document.createElement("caption");
4352
+ caption.className = "viz-sr-only";
4353
+ caption.textContent = layout.a11y.summary;
4354
+ table.appendChild(caption);
4355
+ table.appendChild(renderThead(layout.columns, layout.sort));
4356
+ table.appendChild(renderTbody(layout.rows, layout.columns));
4357
+ scroll.appendChild(table);
4358
+ wrapper.appendChild(scroll);
4359
+ }
4360
+ const pagination = renderPagination(layout);
4361
+ if (pagination) {
4362
+ wrapper.appendChild(pagination);
4363
+ }
4364
+ const footerChrome = renderChromeBlock(layout, "footer");
4365
+ if (footerChrome) {
4366
+ wrapper.appendChild(footerChrome);
4367
+ }
4368
+ const liveRegion = document.createElement("div");
4369
+ liveRegion.className = "viz-table-live-region viz-sr-only";
4370
+ liveRegion.setAttribute("aria-live", "polite");
4371
+ liveRegion.setAttribute("aria-atomic", "true");
4372
+ liveRegion.setAttribute("role", "status");
4373
+ wrapper.appendChild(liveRegion);
4374
+ container.appendChild(wrapper);
4375
+ return wrapper;
4376
+ }
4377
+
4378
+ // src/table-mount.ts
4379
+ function resolveDarkMode3(mode) {
4380
+ if (mode === "force") return true;
4381
+ if (mode === "off" || mode === void 0) return false;
4382
+ if (typeof window !== "undefined" && window.matchMedia) {
4383
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
4384
+ }
4385
+ return false;
4386
+ }
4387
+ function csvEscape2(value) {
4388
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
4389
+ return `"${value.replace(/"/g, '""')}"`;
4390
+ }
4391
+ return value;
4392
+ }
4393
+ function cycleSort(current, column) {
4394
+ if (!current || current.column !== column) {
4395
+ return { column, direction: "asc" };
4396
+ }
4397
+ if (current.direction === "asc") {
4398
+ return { column, direction: "desc" };
4399
+ }
4400
+ return null;
4401
+ }
4402
+ function createTable(container, spec, options) {
4403
+ let currentSpec = spec;
4404
+ let currentLayout;
4405
+ let wrapperElement = null;
4406
+ let disconnectResize = null;
4407
+ let cleanupKeyboard = null;
4408
+ let destroyed = false;
4409
+ const internalState = {
4410
+ sort: null,
4411
+ search: "",
4412
+ page: 0
4413
+ };
4414
+ let searchDebounceTimer = null;
4415
+ let resizeDebounceTimer = null;
4416
+ const isControlled = options?.externalState !== void 0;
4417
+ function getState() {
4418
+ if (isControlled && options?.externalState) {
4419
+ return {
4420
+ sort: options.externalState.sort ?? null,
4421
+ search: options.externalState.search ?? "",
4422
+ page: options.externalState.page ?? 0
4423
+ };
4424
+ }
4425
+ return { ...internalState };
4426
+ }
4427
+ function updateState(partial) {
4428
+ if (isControlled) {
4429
+ const current = getState();
4430
+ const next = {
4431
+ sort: partial.sort !== void 0 ? partial.sort : current.sort,
4432
+ search: partial.search !== void 0 ? partial.search : current.search,
4433
+ page: partial.page !== void 0 ? partial.page : current.page
4434
+ };
4435
+ options?.onStateChange?.(next);
4436
+ } else {
4437
+ if (partial.sort !== void 0) internalState.sort = partial.sort;
4438
+ if (partial.search !== void 0) internalState.search = partial.search;
4439
+ if (partial.page !== void 0) internalState.page = partial.page;
4440
+ options?.onStateChange?.({ ...internalState });
4441
+ }
4442
+ }
4443
+ function compile() {
4444
+ const state = getState();
4445
+ const darkMode = resolveDarkMode3(options?.darkMode);
4446
+ const { width } = getContainerDimensions();
4447
+ const compileOpts = {
4448
+ width,
4449
+ height: 600,
4450
+ theme: options?.theme,
4451
+ darkMode,
4452
+ sort: state.sort ?? void 0,
4453
+ search: state.search || void 0,
4454
+ page: state.page
4455
+ };
4456
+ return compileTable(currentSpec, compileOpts);
4457
+ }
4458
+ function getContainerDimensions() {
4459
+ const rect = container.getBoundingClientRect();
4460
+ return {
4461
+ width: Math.max(rect.width || 600, 100),
4462
+ height: Math.max(rect.height || 400, 100)
4463
+ };
4464
+ }
4465
+ function announce(message) {
4466
+ if (!wrapperElement) return;
4467
+ const liveRegion = wrapperElement.querySelector(".viz-table-live-region");
4468
+ if (liveRegion) {
4469
+ liveRegion.textContent = message;
4470
+ }
4471
+ }
4472
+ function applyBreakpointClass() {
4473
+ if (!wrapperElement) return;
4474
+ const { width } = getContainerDimensions();
4475
+ const bp = getBreakpoint(width);
4476
+ if (bp === "compact" || bp === "medium") {
4477
+ wrapperElement.classList.add("viz-table--compact");
4478
+ } else if (!currentLayout?.compact) {
4479
+ wrapperElement.classList.remove("viz-table--compact");
4480
+ }
4481
+ }
4482
+ function render() {
4483
+ if (destroyed) return;
4484
+ try {
4485
+ if (cleanupKeyboard) {
4486
+ cleanupKeyboard();
4487
+ cleanupKeyboard = null;
4488
+ }
4489
+ if (wrapperElement?.parentNode) {
4490
+ wrapperElement.parentNode.removeChild(wrapperElement);
4491
+ wrapperElement = null;
4492
+ }
4493
+ currentLayout = compile();
4494
+ wrapperElement = renderTable(currentLayout, container);
4495
+ const isDark = resolveDarkMode3(options?.darkMode);
4496
+ if (isDark) {
4497
+ container.classList.add("viz-dark");
4498
+ } else {
4499
+ container.classList.remove("viz-dark");
4500
+ }
4501
+ applyBreakpointClass();
4502
+ if (options?.onRowClick) {
4503
+ wrapperElement.classList.add("viz-table--clickable");
4504
+ }
4505
+ wireEvents();
4506
+ if (wrapperElement) {
4507
+ cleanupKeyboard = attachKeyboardNav({
4508
+ wrapper: wrapperElement,
4509
+ onSort: (columnKey) => {
4510
+ const state = getState();
4511
+ const newSort = cycleSort(state.sort, columnKey);
4512
+ updateState({ sort: newSort, page: 0 });
4513
+ if (newSort) {
4514
+ const dir = newSort.direction === "asc" ? "ascending" : "descending";
4515
+ announce(`Sorted by ${columnKey} ${dir}`);
4516
+ } else {
4517
+ announce("Sort cleared");
4518
+ }
4519
+ if (!isControlled) {
4520
+ rerender();
4521
+ }
4522
+ },
4523
+ onClearSearch: () => {
4524
+ updateState({ search: "", page: 0 });
4525
+ if (!isControlled) {
4526
+ rerender();
4527
+ }
4528
+ },
4529
+ onAnnounce: announce
4530
+ });
4531
+ }
4532
+ } catch (err) {
4533
+ console.error("[viz] Table render failed:", err);
4534
+ }
4535
+ }
4536
+ function wireEvents() {
4537
+ if (!wrapperElement) return;
4538
+ const sortBtns = wrapperElement.querySelectorAll("[data-sort-column]");
4539
+ for (const btn of sortBtns) {
4540
+ btn.addEventListener("click", handleSortClick);
4541
+ }
4542
+ const searchInput = wrapperElement.querySelector(
4543
+ ".viz-table-search input"
4544
+ );
4545
+ if (searchInput) {
4546
+ searchInput.addEventListener("input", handleSearchInput);
4547
+ }
4548
+ const pageButtons = wrapperElement.querySelectorAll("[data-page-action]");
4549
+ for (const btn of pageButtons) {
4550
+ btn.addEventListener("click", handlePageClick);
4551
+ }
4552
+ if (options?.onRowClick) {
4553
+ const rows = wrapperElement.querySelectorAll("tbody tr");
4554
+ for (const row of rows) {
4555
+ row.addEventListener("click", handleRowClick);
4556
+ }
4557
+ }
4558
+ }
4559
+ function handleSortClick(e) {
4560
+ const btn = e.currentTarget;
4561
+ const column = btn.getAttribute("data-sort-column");
4562
+ if (!column) return;
4563
+ const state = getState();
4564
+ const newSort = cycleSort(state.sort, column);
4565
+ updateState({ sort: newSort, page: 0 });
4566
+ if (newSort) {
4567
+ const dir = newSort.direction === "asc" ? "ascending" : "descending";
4568
+ announce(`Sorted by ${column} ${dir}`);
4569
+ } else {
4570
+ announce("Sort cleared");
4571
+ }
4572
+ if (!isControlled) {
4573
+ rerender();
4574
+ }
4575
+ }
4576
+ function handleSearchInput(e) {
4577
+ const input = e.target;
4578
+ const query = input.value;
4579
+ if (searchDebounceTimer !== null) {
4580
+ clearTimeout(searchDebounceTimer);
4581
+ }
4582
+ searchDebounceTimer = setTimeout(() => {
4583
+ searchDebounceTimer = null;
4584
+ updateState({ search: query, page: 0 });
4585
+ if (!isControlled) {
4586
+ rerender();
4587
+ const rowCount = currentLayout?.rows?.length ?? 0;
4588
+ if (query) {
4589
+ announce(`${rowCount} result${rowCount !== 1 ? "s" : ""} found`);
4590
+ }
4591
+ }
4592
+ }, 200);
4593
+ }
4594
+ function handlePageClick(e) {
4595
+ const btn = e.currentTarget;
4596
+ const action = btn.getAttribute("data-page-action");
4597
+ const state = getState();
4598
+ if (action === "prev" && state.page > 0) {
4599
+ updateState({ page: state.page - 1 });
4600
+ } else if (action === "next") {
4601
+ updateState({ page: state.page + 1 });
4602
+ }
4603
+ if (!isControlled) {
4604
+ rerender();
4605
+ }
4606
+ }
4607
+ function handleRowClick(e) {
4608
+ const tr = e.currentTarget;
4609
+ const rowId = tr.getAttribute("data-row-id");
4610
+ if (!rowId || !currentLayout) return;
4611
+ const row = currentLayout.rows.find((r) => r.id === rowId);
4612
+ if (row) {
4613
+ options?.onRowClick?.(row.data);
4614
+ }
4615
+ }
4616
+ function rerender() {
4617
+ if (destroyed) return;
4618
+ const searchInput = wrapperElement?.querySelector(
4619
+ ".viz-table-search input"
4620
+ );
4621
+ const hadFocus = searchInput && document.activeElement === searchInput;
4622
+ const selectionStart = searchInput?.selectionStart ?? 0;
4623
+ const selectionEnd = searchInput?.selectionEnd ?? 0;
4624
+ render();
4625
+ if (hadFocus) {
4626
+ const newInput = wrapperElement?.querySelector(
4627
+ ".viz-table-search input"
4628
+ );
4629
+ if (newInput) {
4630
+ newInput.focus();
4631
+ newInput.setSelectionRange(selectionStart, selectionEnd);
4632
+ }
4633
+ }
4634
+ }
4635
+ function update(newSpec) {
4636
+ if (destroyed) return;
4637
+ currentSpec = newSpec;
4638
+ render();
4639
+ }
4640
+ function resize() {
4641
+ if (destroyed) return;
4642
+ render();
4643
+ }
4644
+ function doExport(format) {
4645
+ if (format !== "csv") {
4646
+ throw new Error(`Unsupported export format: ${format}`);
4647
+ }
4648
+ const state = getState();
4649
+ const darkMode = resolveDarkMode3(options?.darkMode);
4650
+ const { width } = getContainerDimensions();
4651
+ const fullLayout = compileTable(currentSpec, {
4652
+ width,
4653
+ height: 600,
4654
+ theme: options?.theme,
4655
+ darkMode,
4656
+ sort: state.sort ?? void 0,
4657
+ search: state.search || void 0
4658
+ // No page/pageSize: get all rows
4659
+ });
4660
+ const headers = fullLayout.columns.map((c) => c.label);
4661
+ const csvRows = [headers.map(csvEscape2).join(",")];
4662
+ for (const row of fullLayout.rows) {
4663
+ const values = row.cells.map((cell) => csvEscape2(cell.formattedValue));
4664
+ csvRows.push(values.join(","));
4665
+ }
4666
+ return csvRows.join("\n");
4667
+ }
4668
+ function setState(partial) {
4669
+ if (destroyed) return;
4670
+ if (partial.sort !== void 0) internalState.sort = partial.sort;
4671
+ if (partial.search !== void 0) internalState.search = partial.search;
4672
+ if (partial.page !== void 0) internalState.page = partial.page;
4673
+ render();
4674
+ }
4675
+ function destroy() {
4676
+ if (destroyed) return;
4677
+ destroyed = true;
4678
+ if (cleanupKeyboard) {
4679
+ cleanupKeyboard();
4680
+ cleanupKeyboard = null;
4681
+ }
4682
+ if (searchDebounceTimer !== null) {
4683
+ clearTimeout(searchDebounceTimer);
4684
+ searchDebounceTimer = null;
4685
+ }
4686
+ if (resizeDebounceTimer !== null) {
4687
+ clearTimeout(resizeDebounceTimer);
4688
+ resizeDebounceTimer = null;
4689
+ }
4690
+ if (disconnectResize) {
4691
+ disconnectResize();
4692
+ disconnectResize = null;
4693
+ }
4694
+ if (wrapperElement?.parentNode) {
4695
+ wrapperElement.parentNode.removeChild(wrapperElement);
4696
+ wrapperElement = null;
4697
+ }
4698
+ container.classList.remove("viz-dark");
4699
+ }
4700
+ render();
4701
+ if (options?.responsive !== false) {
4702
+ disconnectResize = observeResize(container, () => {
4703
+ if (resizeDebounceTimer !== null) {
4704
+ clearTimeout(resizeDebounceTimer);
4705
+ }
4706
+ resizeDebounceTimer = setTimeout(() => {
4707
+ resizeDebounceTimer = null;
4708
+ applyBreakpointClass();
4709
+ resize();
4710
+ }, 100);
4711
+ });
4712
+ }
4713
+ return {
4714
+ update,
4715
+ resize,
4716
+ export: doExport,
4717
+ getState,
4718
+ setState,
4719
+ destroy
4720
+ };
4721
+ }
4722
+ export {
4723
+ attachKeyboardNav,
4724
+ createChart,
4725
+ createGraph,
4726
+ createSimulationWorker,
4727
+ createTable,
4728
+ createTooltipManager,
4729
+ exportCSV,
4730
+ exportPNG,
4731
+ exportSVG,
4732
+ observeResize,
4733
+ registerMarkRenderer,
4734
+ renderBarCell,
4735
+ renderCategoryCell,
4736
+ renderCell,
4737
+ renderChartSVG,
4738
+ renderFlagCell,
4739
+ renderHeatmapCell,
4740
+ renderImageCell,
4741
+ renderSparklineCell,
4742
+ renderTable,
4743
+ renderTextCell
4744
+ };
4745
+ //# sourceMappingURL=index.js.map