@opendata-ai/openchart-vanilla 2.1.0 → 2.2.1
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 +28 -3
- package/dist/index.js +217 -24
- package/dist/index.js.map +1 -1
- package/dist/simulation-worker.js +9 -1
- package/package.json +3 -3
- package/src/__tests__/export.test.ts +29 -3
- package/src/export.ts +70 -0
- package/src/graph/canvas-renderer.ts +38 -10
- package/src/graph/interaction.ts +8 -0
- package/src/graph/simulation-worker.ts +19 -8
- package/src/graph/simulation.ts +16 -8
- package/src/graph/types.ts +1 -0
- package/src/graph/worker-protocol.ts +3 -0
- package/src/graph-mount.ts +174 -6
- package/src/index.ts +2 -2
- package/src/mount.ts +21 -7
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,22 @@ interface PNGExportOptions {
|
|
|
27
27
|
* @returns A Promise resolving to the PNG Blob.
|
|
28
28
|
*/
|
|
29
29
|
declare function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob>;
|
|
30
|
+
interface JPGExportOptions extends PNGExportOptions {
|
|
31
|
+
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
32
|
+
quality?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Render an SVG element to a JPEG Blob via a canvas.
|
|
36
|
+
*
|
|
37
|
+
* Same pipeline as exportPNG but outputs JPEG with configurable quality.
|
|
38
|
+
* The canvas is filled with white before drawing to avoid transparent
|
|
39
|
+
* backgrounds rendering as black in JPEG format.
|
|
40
|
+
*
|
|
41
|
+
* @param svgElement - The rendered SVG element.
|
|
42
|
+
* @param options - Optional DPI scaling and JPEG quality.
|
|
43
|
+
* @returns A Promise resolving to the JPEG Blob.
|
|
44
|
+
*/
|
|
45
|
+
declare function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob>;
|
|
30
46
|
/**
|
|
31
47
|
* Convert an array of data objects to a CSV string.
|
|
32
48
|
*
|
|
@@ -72,12 +88,20 @@ interface GraphMountOptions {
|
|
|
72
88
|
theme?: ThemeConfig;
|
|
73
89
|
darkMode?: DarkMode;
|
|
74
90
|
responsive?: boolean;
|
|
91
|
+
/** Show the built-in tooltip on node/edge hover. Defaults to true. */
|
|
92
|
+
tooltip?: boolean;
|
|
93
|
+
/** Show the built-in legend. Defaults to true. */
|
|
94
|
+
legend?: boolean;
|
|
75
95
|
onNodeClick?: (node: Record<string, unknown>) => void;
|
|
76
96
|
onNodeDoubleClick?: (node: Record<string, unknown>) => void;
|
|
97
|
+
onNodeHover?: (node: Record<string, unknown> | null) => void;
|
|
98
|
+
onEdgeHover?: (edge: Record<string, unknown> | null) => void;
|
|
77
99
|
onSelectionChange?: (nodeIds: string[]) => void;
|
|
78
100
|
}
|
|
79
101
|
interface GraphInstance {
|
|
80
102
|
update(spec: GraphSpec): void;
|
|
103
|
+
/** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */
|
|
104
|
+
updateVisuals(spec: GraphSpec): void;
|
|
81
105
|
search(query: string): void;
|
|
82
106
|
clearSearch(): void;
|
|
83
107
|
zoomToFit(): void;
|
|
@@ -116,7 +140,7 @@ interface MountOptions extends ChartEventHandlers {
|
|
|
116
140
|
/** Enable responsive resizing. Defaults to true. */
|
|
117
141
|
responsive?: boolean;
|
|
118
142
|
}
|
|
119
|
-
interface ExportOptions extends
|
|
143
|
+
interface ExportOptions extends JPGExportOptions {
|
|
120
144
|
}
|
|
121
145
|
interface ChartInstance {
|
|
122
146
|
/** Re-compile and re-render with a new spec. */
|
|
@@ -126,8 +150,9 @@ interface ChartInstance {
|
|
|
126
150
|
/** Export the chart. */
|
|
127
151
|
export(format: 'svg'): string;
|
|
128
152
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
153
|
+
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
129
154
|
export(format: 'csv'): string;
|
|
130
|
-
export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
155
|
+
export(format: 'svg' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
131
156
|
/** Remove all DOM elements and disconnect observers. */
|
|
132
157
|
destroy(): void;
|
|
133
158
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -324,4 +349,4 @@ interface TooltipManager {
|
|
|
324
349
|
*/
|
|
325
350
|
declare function createTooltipManager(container: HTMLElement): TooltipManager;
|
|
326
351
|
|
|
327
|
-
export { type ChartInstance, type ExportOptions, type GraphInstance, type GraphMountOptions, type KeyboardNavOptions, type MountOptions, type PNGExportOptions, type TableInstance, type TableMountOptions, type TableState, type TooltipManager, attachKeyboardNav, createChart, createGraph, createSimulationWorker, createTable, createTooltipManager, exportCSV, exportPNG, exportSVG, observeResize, registerMarkRenderer, renderBarCell, renderCategoryCell, renderCell, renderChartSVG, renderFlagCell, renderHeatmapCell, renderImageCell, renderSparklineCell, renderTable, renderTextCell };
|
|
352
|
+
export { type ChartInstance, type ExportOptions, type GraphInstance, type GraphMountOptions, type JPGExportOptions, type KeyboardNavOptions, type MountOptions, type PNGExportOptions, type TableInstance, type TableMountOptions, type TableState, type TooltipManager, attachKeyboardNav, createChart, createGraph, createSimulationWorker, createTable, createTooltipManager, exportCSV, exportJPG, exportPNG, exportSVG, observeResize, registerMarkRenderer, renderBarCell, renderCategoryCell, renderCell, renderChartSVG, renderFlagCell, renderHeatmapCell, renderImageCell, renderSparklineCell, renderTable, renderTextCell };
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,48 @@ async function exportPNG(svgElement, options) {
|
|
|
38
38
|
img.src = url;
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
+
async function exportJPG(svgElement, options) {
|
|
42
|
+
const dpi = options?.dpi ?? 2;
|
|
43
|
+
const quality = options?.quality ?? 0.92;
|
|
44
|
+
const svgString = exportSVG(svgElement);
|
|
45
|
+
const width = parseFloat(svgElement.getAttribute("width") || "600");
|
|
46
|
+
const height = parseFloat(svgElement.getAttribute("height") || "400");
|
|
47
|
+
const canvas = document.createElement("canvas");
|
|
48
|
+
canvas.width = width * dpi;
|
|
49
|
+
canvas.height = height * dpi;
|
|
50
|
+
const ctx = canvas.getContext("2d");
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
throw new Error("Canvas 2D context not available");
|
|
53
|
+
}
|
|
54
|
+
ctx.fillStyle = "#ffffff";
|
|
55
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
56
|
+
ctx.scale(dpi, dpi);
|
|
57
|
+
const img = new Image();
|
|
58
|
+
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
|
59
|
+
const url = URL.createObjectURL(blob);
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
img.onload = () => {
|
|
62
|
+
ctx.drawImage(img, 0, 0);
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
canvas.toBlob(
|
|
65
|
+
(result) => {
|
|
66
|
+
if (result) {
|
|
67
|
+
resolve(result);
|
|
68
|
+
} else {
|
|
69
|
+
reject(new Error("Canvas toBlob returned null"));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"image/jpeg",
|
|
73
|
+
quality
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
img.onerror = () => {
|
|
77
|
+
URL.revokeObjectURL(url);
|
|
78
|
+
reject(new Error("Failed to load SVG as image"));
|
|
79
|
+
};
|
|
80
|
+
img.src = url;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
41
83
|
function exportCSV(data) {
|
|
42
84
|
if (data.length === 0) return "";
|
|
43
85
|
const headers = Object.keys(data[0]);
|
|
@@ -65,15 +107,15 @@ function createSimulationWorker() {
|
|
|
65
107
|
import { compileGraph } from "@opendata-ai/openchart-engine";
|
|
66
108
|
|
|
67
109
|
// src/graph/canvas-renderer.ts
|
|
68
|
-
var LABEL_FONT_MIN =
|
|
69
|
-
var LABEL_FONT_MAX =
|
|
110
|
+
var LABEL_FONT_MIN = 8;
|
|
111
|
+
var LABEL_FONT_MAX = 12;
|
|
70
112
|
var EDGE_ALPHA_DEFAULT = 0.35;
|
|
71
113
|
var EDGE_ALPHA_CONNECTED = 1;
|
|
72
114
|
var EDGE_ALPHA_DIMMED = 0.05;
|
|
73
115
|
var SEARCH_NON_MATCH_ALPHA = 0.15;
|
|
74
116
|
var GLOW_NODE_THRESHOLD = 2e3;
|
|
75
|
-
var GLOW_RADIUS_MULTIPLIER = 1.
|
|
76
|
-
var GLOW_ALPHA = 0.
|
|
117
|
+
var GLOW_RADIUS_MULTIPLIER = 1.3;
|
|
118
|
+
var GLOW_ALPHA = 0.15;
|
|
77
119
|
var CULL_MARGIN = 50;
|
|
78
120
|
var TWO_PI = Math.PI * 2;
|
|
79
121
|
var MIN_SCREEN_RADIUS = 2.5;
|
|
@@ -132,6 +174,7 @@ var GraphCanvasRenderer = class {
|
|
|
132
174
|
edges,
|
|
133
175
|
transform,
|
|
134
176
|
hoveredNodeId,
|
|
177
|
+
hoveredEdgeId,
|
|
135
178
|
selectedNodeIds,
|
|
136
179
|
adjacencyMap,
|
|
137
180
|
theme,
|
|
@@ -160,8 +203,10 @@ var GraphCanvasRenderer = class {
|
|
|
160
203
|
ctx.save();
|
|
161
204
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
162
205
|
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
163
|
-
|
|
164
|
-
|
|
206
|
+
if (theme.colors.background !== "transparent") {
|
|
207
|
+
ctx.fillStyle = theme.colors.background;
|
|
208
|
+
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
209
|
+
}
|
|
165
210
|
ctx.translate(transform.x, transform.y);
|
|
166
211
|
ctx.scale(transform.k, transform.k);
|
|
167
212
|
this.drawEdgesBatched(
|
|
@@ -169,7 +214,8 @@ var GraphCanvasRenderer = class {
|
|
|
169
214
|
visibleEdges,
|
|
170
215
|
hasActiveNode,
|
|
171
216
|
connectedNodeIds,
|
|
172
|
-
isGesturing ? null : searchMatches
|
|
217
|
+
isGesturing ? null : searchMatches,
|
|
218
|
+
hoveredEdgeId
|
|
173
219
|
);
|
|
174
220
|
this.drawNodesBatched(
|
|
175
221
|
ctx,
|
|
@@ -218,11 +264,17 @@ var GraphCanvasRenderer = class {
|
|
|
218
264
|
// -------------------------------------------------------------------------
|
|
219
265
|
// Batched edge drawing
|
|
220
266
|
// -------------------------------------------------------------------------
|
|
221
|
-
drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches) {
|
|
267
|
+
drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches, hoveredEdgeId) {
|
|
222
268
|
const dimmedEdges = [];
|
|
223
269
|
const defaultEdges = [];
|
|
224
270
|
const connectedEdges = [];
|
|
271
|
+
let hoveredEdge = null;
|
|
225
272
|
for (const edge of edges) {
|
|
273
|
+
const edgeId = `${edge.source}->${edge.target}`;
|
|
274
|
+
if (edgeId === hoveredEdgeId) {
|
|
275
|
+
hoveredEdge = edge;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
226
278
|
const isConnected = hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
|
|
227
279
|
const isDimmed = hasActiveNode && !isConnected;
|
|
228
280
|
if (isConnected) {
|
|
@@ -236,6 +288,19 @@ var GraphCanvasRenderer = class {
|
|
|
236
288
|
this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
|
|
237
289
|
this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
|
|
238
290
|
this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
|
|
291
|
+
if (hoveredEdge) {
|
|
292
|
+
const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
|
|
293
|
+
ctx.setLineDash(dash);
|
|
294
|
+
ctx.strokeStyle = hoveredEdge.stroke;
|
|
295
|
+
ctx.lineWidth = hoveredEdge.strokeWidth * 2;
|
|
296
|
+
ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
|
|
297
|
+
ctx.beginPath();
|
|
298
|
+
ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
|
|
299
|
+
ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
ctx.setLineDash([]);
|
|
302
|
+
ctx.globalAlpha = 1;
|
|
303
|
+
}
|
|
239
304
|
}
|
|
240
305
|
/**
|
|
241
306
|
* Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
|
|
@@ -479,7 +544,7 @@ var GraphCanvasRenderer = class {
|
|
|
479
544
|
// Labels (drawn individually, skipped during gestures)
|
|
480
545
|
// -------------------------------------------------------------------------
|
|
481
546
|
drawLabels(ctx, nodes, threshold, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme) {
|
|
482
|
-
const rawSize =
|
|
547
|
+
const rawSize = 10 / zoom;
|
|
483
548
|
const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
|
|
484
549
|
ctx.font = `${fontSize}px ${theme.fonts.family}`;
|
|
485
550
|
ctx.textAlign = "center";
|
|
@@ -493,7 +558,7 @@ var GraphCanvasRenderer = class {
|
|
|
493
558
|
if (!forced && node.labelPriority < threshold) continue;
|
|
494
559
|
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
495
560
|
const labelY = node.y + node.radius + 3;
|
|
496
|
-
ctx.strokeStyle = theme.colors.background;
|
|
561
|
+
ctx.strokeStyle = theme.colors.background === "transparent" ? "rgba(0, 0, 0, 0.7)" : theme.colors.background;
|
|
497
562
|
ctx.lineWidth = 3;
|
|
498
563
|
ctx.lineJoin = "round";
|
|
499
564
|
ctx.strokeText(node.label, node.x, labelY);
|
|
@@ -718,6 +783,10 @@ var GraphInteractionManager = class {
|
|
|
718
783
|
}
|
|
719
784
|
const hitId = this.hitTest(x, y);
|
|
720
785
|
this.callbacks.onHoverChange(hitId);
|
|
786
|
+
if (!hitId) {
|
|
787
|
+
const graph = this.transform.screenToGraph(x, y);
|
|
788
|
+
this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
|
|
789
|
+
}
|
|
721
790
|
this.canvas.style.cursor = hitId ? "pointer" : "default";
|
|
722
791
|
}
|
|
723
792
|
onMouseUp(e) {
|
|
@@ -1216,13 +1285,18 @@ var SimulationManager = class _SimulationManager {
|
|
|
1216
1285
|
community: n.community
|
|
1217
1286
|
}));
|
|
1218
1287
|
this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1288
|
+
const linkForce = forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
|
|
1289
|
+
if (config.linkStrength != null) {
|
|
1290
|
+
linkForce.strength(config.linkStrength);
|
|
1291
|
+
}
|
|
1292
|
+
const padding = config.collisionPadding ?? 2;
|
|
1293
|
+
this.syncSim = forceSimulation(this.syncNodes).force("link", linkForce).force("charge", forceManyBody().strength(config.chargeStrength)).force(
|
|
1223
1294
|
"collide",
|
|
1224
|
-
forceCollide().radius((d) => d.radius +
|
|
1295
|
+
forceCollide().radius((d) => d.radius + padding)
|
|
1225
1296
|
).force("gravityX", forceX(0).strength(0.05)).force("gravityY", forceY(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
|
|
1297
|
+
if (config.centerForce !== false) {
|
|
1298
|
+
this.syncSim.force("center", forceCenter(0, 0));
|
|
1299
|
+
}
|
|
1226
1300
|
if (config.clustering) {
|
|
1227
1301
|
const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
|
|
1228
1302
|
this.syncSim.force("cluster", clusterFn);
|
|
@@ -1467,6 +1541,7 @@ function createGraph(container, spec, options) {
|
|
|
1467
1541
|
let positionedEdges = [];
|
|
1468
1542
|
let adjacencyMap = /* @__PURE__ */ new Map();
|
|
1469
1543
|
let hoveredNodeId = null;
|
|
1544
|
+
let hoveredEdgeId = null;
|
|
1470
1545
|
let selectedNodeIds = /* @__PURE__ */ new Set();
|
|
1471
1546
|
let animFrameId = null;
|
|
1472
1547
|
let needsRender = false;
|
|
@@ -1527,6 +1602,38 @@ function createGraph(container, spec, options) {
|
|
|
1527
1602
|
const node = compilation.nodes.find((n) => n.id === nodeId);
|
|
1528
1603
|
return node?.data ?? {};
|
|
1529
1604
|
}
|
|
1605
|
+
function pointToSegmentDist(px, py, ax, ay, bx, by) {
|
|
1606
|
+
const dx = bx - ax;
|
|
1607
|
+
const dy = by - ay;
|
|
1608
|
+
const lenSq = dx * dx + dy * dy;
|
|
1609
|
+
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
|
|
1610
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
1611
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
1612
|
+
}
|
|
1613
|
+
function hitTestEdge(graphX, graphY, threshold) {
|
|
1614
|
+
let bestDist = threshold;
|
|
1615
|
+
let bestEdgeId = null;
|
|
1616
|
+
for (const edge of positionedEdges) {
|
|
1617
|
+
const dist = pointToSegmentDist(
|
|
1618
|
+
graphX,
|
|
1619
|
+
graphY,
|
|
1620
|
+
edge.sourceX,
|
|
1621
|
+
edge.sourceY,
|
|
1622
|
+
edge.targetX,
|
|
1623
|
+
edge.targetY
|
|
1624
|
+
);
|
|
1625
|
+
if (dist < bestDist) {
|
|
1626
|
+
bestDist = dist;
|
|
1627
|
+
bestEdgeId = `${edge.source}->${edge.target}`;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return bestEdgeId;
|
|
1631
|
+
}
|
|
1632
|
+
function edgeDataById(edgeId) {
|
|
1633
|
+
const [source, target] = edgeId.split("->");
|
|
1634
|
+
const edge = compilation.edges.find((e) => e.source === source && e.target === target);
|
|
1635
|
+
return edge?.data ?? null;
|
|
1636
|
+
}
|
|
1530
1637
|
function createDOM() {
|
|
1531
1638
|
const { width, height } = getContainerDimensions();
|
|
1532
1639
|
const isDark = resolveDarkMode(options?.darkMode);
|
|
@@ -1548,10 +1655,12 @@ function createGraph(container, spec, options) {
|
|
|
1548
1655
|
canvas.setAttribute("aria-label", compilation.a11y.altText);
|
|
1549
1656
|
}
|
|
1550
1657
|
wrapper.appendChild(canvas);
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1658
|
+
if (options?.legend !== false) {
|
|
1659
|
+
legendEl = document.createElement("div");
|
|
1660
|
+
legendEl.className = "viz-graph-legend";
|
|
1661
|
+
renderLegend2();
|
|
1662
|
+
wrapper.appendChild(legendEl);
|
|
1663
|
+
}
|
|
1555
1664
|
container.appendChild(wrapper);
|
|
1556
1665
|
const chromeHeight = chromeEl.getBoundingClientRect().height || 0;
|
|
1557
1666
|
const canvasHeight = Math.max(height - chromeHeight, 200);
|
|
@@ -1601,7 +1710,10 @@ function createGraph(container, spec, options) {
|
|
|
1601
1710
|
clustering: config.clustering,
|
|
1602
1711
|
alphaDecay: config.alphaDecay,
|
|
1603
1712
|
velocityDecay: config.velocityDecay,
|
|
1604
|
-
collisionRadius: config.collisionRadius
|
|
1713
|
+
collisionRadius: config.collisionRadius,
|
|
1714
|
+
collisionPadding: config.collisionPadding,
|
|
1715
|
+
linkStrength: config.linkStrength,
|
|
1716
|
+
centerForce: config.centerForce
|
|
1605
1717
|
});
|
|
1606
1718
|
simulation.onTick((positions, _alpha) => {
|
|
1607
1719
|
if (destroyed) return;
|
|
@@ -1661,6 +1773,7 @@ function createGraph(container, spec, options) {
|
|
|
1661
1773
|
edges: positionedEdges,
|
|
1662
1774
|
transform: { x: transform.x, y: transform.y, k: transform.k },
|
|
1663
1775
|
hoveredNodeId,
|
|
1776
|
+
hoveredEdgeId,
|
|
1664
1777
|
selectedNodeIds,
|
|
1665
1778
|
adjacencyMap,
|
|
1666
1779
|
theme: compilation.theme,
|
|
@@ -1672,7 +1785,9 @@ function createGraph(container, spec, options) {
|
|
|
1672
1785
|
}
|
|
1673
1786
|
function initInteraction() {
|
|
1674
1787
|
if (!canvas) return;
|
|
1675
|
-
|
|
1788
|
+
if (options?.tooltip !== false) {
|
|
1789
|
+
tooltipManager = createTooltipManager(wrapper);
|
|
1790
|
+
}
|
|
1676
1791
|
interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
|
|
1677
1792
|
onTransformChange(_transform) {
|
|
1678
1793
|
markGesture();
|
|
@@ -1683,7 +1798,16 @@ function createGraph(container, spec, options) {
|
|
|
1683
1798
|
hoveredNodeId = nodeId;
|
|
1684
1799
|
needsRender = true;
|
|
1685
1800
|
scheduleRender();
|
|
1801
|
+
if (nodeId) {
|
|
1802
|
+
options?.onNodeHover?.(nodeDataById(nodeId));
|
|
1803
|
+
} else {
|
|
1804
|
+
options?.onNodeHover?.(null);
|
|
1805
|
+
}
|
|
1686
1806
|
if (nodeId && tooltipManager) {
|
|
1807
|
+
if (hoveredEdgeId) {
|
|
1808
|
+
hoveredEdgeId = null;
|
|
1809
|
+
options?.onEdgeHover?.(null);
|
|
1810
|
+
}
|
|
1687
1811
|
const content = compilation.tooltipDescriptors.get(nodeId);
|
|
1688
1812
|
if (content) {
|
|
1689
1813
|
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
@@ -1692,10 +1816,35 @@ function createGraph(container, spec, options) {
|
|
|
1692
1816
|
tooltipManager.show(content, screen.x, screen.y);
|
|
1693
1817
|
}
|
|
1694
1818
|
}
|
|
1695
|
-
} else {
|
|
1819
|
+
} else if (!nodeId) {
|
|
1696
1820
|
tooltipManager?.hide();
|
|
1697
1821
|
}
|
|
1698
1822
|
},
|
|
1823
|
+
onBackgroundHover(graphX, graphY, screenX, screenY) {
|
|
1824
|
+
const transform = interactionManager?.getTransform();
|
|
1825
|
+
const threshold = 5 / (transform?.k ?? 1);
|
|
1826
|
+
const edgeId = hitTestEdge(graphX, graphY, threshold);
|
|
1827
|
+
if (edgeId !== hoveredEdgeId) {
|
|
1828
|
+
hoveredEdgeId = edgeId;
|
|
1829
|
+
needsRender = true;
|
|
1830
|
+
scheduleRender();
|
|
1831
|
+
if (edgeId) {
|
|
1832
|
+
const data = edgeDataById(edgeId);
|
|
1833
|
+
options?.onEdgeHover?.(data);
|
|
1834
|
+
if (tooltipManager && data) {
|
|
1835
|
+
const fields = Object.entries(data).filter(([key]) => key !== "source" && key !== "target").filter(([, value]) => value != null).map(([key, value]) => ({
|
|
1836
|
+
label: key,
|
|
1837
|
+
value: typeof value === "number" ? value.toLocaleString() : String(value)
|
|
1838
|
+
}));
|
|
1839
|
+
const [source, target] = edgeId.split("->");
|
|
1840
|
+
tooltipManager.show({ title: `${source} \u2192 ${target}`, fields }, screenX, screenY);
|
|
1841
|
+
}
|
|
1842
|
+
} else {
|
|
1843
|
+
options?.onEdgeHover?.(null);
|
|
1844
|
+
tooltipManager?.hide();
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
},
|
|
1699
1848
|
onSelectionChange(nodeIds) {
|
|
1700
1849
|
selectedNodeIds = new Set(nodeIds);
|
|
1701
1850
|
needsRender = true;
|
|
@@ -1821,9 +1970,40 @@ function createGraph(container, spec, options) {
|
|
|
1821
1970
|
initSimulation();
|
|
1822
1971
|
initInteraction();
|
|
1823
1972
|
hoveredNodeId = null;
|
|
1973
|
+
hoveredEdgeId = null;
|
|
1824
1974
|
selectedNodeIds = /* @__PURE__ */ new Set();
|
|
1825
1975
|
searchManager.clearSearch();
|
|
1826
1976
|
}
|
|
1977
|
+
function updateVisuals(newSpec) {
|
|
1978
|
+
if (destroyed) return;
|
|
1979
|
+
currentSpec = newSpec;
|
|
1980
|
+
const posMap = /* @__PURE__ */ new Map();
|
|
1981
|
+
for (const node of positionedNodes) {
|
|
1982
|
+
posMap.set(node.id, { x: node.x, y: node.y });
|
|
1983
|
+
}
|
|
1984
|
+
compilation = compile();
|
|
1985
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
1986
|
+
positionedNodes = compilation.nodes.map((node) => {
|
|
1987
|
+
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
|
1988
|
+
return { ...node, x: pos.x, y: pos.y };
|
|
1989
|
+
});
|
|
1990
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
1991
|
+
const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
|
|
1992
|
+
const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
|
|
1993
|
+
return {
|
|
1994
|
+
...edge,
|
|
1995
|
+
sourceX: src.x,
|
|
1996
|
+
sourceY: src.y,
|
|
1997
|
+
targetX: tgt.x,
|
|
1998
|
+
targetY: tgt.y
|
|
1999
|
+
};
|
|
2000
|
+
});
|
|
2001
|
+
spatialIndex.rebuild(positionedNodes);
|
|
2002
|
+
renderChrome2();
|
|
2003
|
+
renderLegend2();
|
|
2004
|
+
needsRender = true;
|
|
2005
|
+
scheduleRender();
|
|
2006
|
+
}
|
|
1827
2007
|
function teardownSubsystems() {
|
|
1828
2008
|
if (animFrameId !== null) {
|
|
1829
2009
|
cancelAnimationFrame(animFrameId);
|
|
@@ -1873,6 +2053,8 @@ function createGraph(container, spec, options) {
|
|
|
1873
2053
|
return {
|
|
1874
2054
|
update() {
|
|
1875
2055
|
},
|
|
2056
|
+
updateVisuals() {
|
|
2057
|
+
},
|
|
1876
2058
|
search() {
|
|
1877
2059
|
},
|
|
1878
2060
|
clearSearch() {
|
|
@@ -1897,6 +2079,7 @@ function createGraph(container, spec, options) {
|
|
|
1897
2079
|
}
|
|
1898
2080
|
return {
|
|
1899
2081
|
update,
|
|
2082
|
+
updateVisuals,
|
|
1900
2083
|
search,
|
|
1901
2084
|
clearSearch,
|
|
1902
2085
|
zoomToFit,
|
|
@@ -3343,7 +3526,7 @@ function wireSeriesLabelDrag(svg, spec, onEdit, setDragging) {
|
|
|
3343
3526
|
}
|
|
3344
3527
|
};
|
|
3345
3528
|
}
|
|
3346
|
-
function wireLegendInteraction(svg, _layout, onLegendToggle) {
|
|
3529
|
+
function wireLegendInteraction(svg, _layout, onLegendToggle, onEdit) {
|
|
3347
3530
|
const legendEntries = svg.querySelectorAll("[data-legend-index]");
|
|
3348
3531
|
const cleanups = [];
|
|
3349
3532
|
const hiddenSeries = /* @__PURE__ */ new Set();
|
|
@@ -3356,11 +3539,13 @@ function wireLegendInteraction(svg, _layout, onLegendToggle) {
|
|
|
3356
3539
|
entry.setAttribute("opacity", "1");
|
|
3357
3540
|
entry.setAttribute("aria-label", `${label}: visible`);
|
|
3358
3541
|
onLegendToggle?.(label, true);
|
|
3542
|
+
onEdit?.({ type: "legend-toggle", series: label, hidden: false });
|
|
3359
3543
|
} else {
|
|
3360
3544
|
hiddenSeries.add(label);
|
|
3361
3545
|
entry.setAttribute("opacity", "0.3");
|
|
3362
3546
|
entry.setAttribute("aria-label", `${label}: hidden`);
|
|
3363
3547
|
onLegendToggle?.(label, false);
|
|
3548
|
+
onEdit?.({ type: "legend-toggle", series: label, hidden: true });
|
|
3364
3549
|
}
|
|
3365
3550
|
const marks = svg.querySelectorAll(".viz-mark");
|
|
3366
3551
|
for (const mark of marks) {
|
|
@@ -3593,7 +3778,12 @@ function createChart(container, spec, options) {
|
|
|
3593
3778
|
tooltipManager,
|
|
3594
3779
|
currentLayout
|
|
3595
3780
|
);
|
|
3596
|
-
cleanupLegend = wireLegendInteraction(
|
|
3781
|
+
cleanupLegend = wireLegendInteraction(
|
|
3782
|
+
svgElement,
|
|
3783
|
+
currentLayout,
|
|
3784
|
+
options?.onLegendToggle,
|
|
3785
|
+
options?.onEdit
|
|
3786
|
+
);
|
|
3597
3787
|
if (options?.onMarkClick || options?.onMarkHover || options?.onMarkLeave || options?.onAnnotationClick) {
|
|
3598
3788
|
const specAnnotations = "annotations" in currentSpec && Array.isArray(currentSpec.annotations) ? currentSpec.annotations : [];
|
|
3599
3789
|
cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
|
|
@@ -3659,6 +3849,8 @@ function createChart(container, spec, options) {
|
|
|
3659
3849
|
return exportSVG(svgElement);
|
|
3660
3850
|
case "png":
|
|
3661
3851
|
return exportPNG(svgElement, exportOptions);
|
|
3852
|
+
case "jpg":
|
|
3853
|
+
return exportJPG(svgElement, exportOptions);
|
|
3662
3854
|
case "csv":
|
|
3663
3855
|
return exportCSV(
|
|
3664
3856
|
"data" in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : []
|
|
@@ -4828,6 +5020,7 @@ export {
|
|
|
4828
5020
|
createTable,
|
|
4829
5021
|
createTooltipManager,
|
|
4830
5022
|
exportCSV,
|
|
5023
|
+
exportJPG,
|
|
4831
5024
|
exportPNG,
|
|
4832
5025
|
exportSVG,
|
|
4833
5026
|
observeResize,
|