@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.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|