@opendata-ai/openchart-vanilla 2.0.0 → 2.2.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/README.md +102 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +299 -11
- 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/__tests__/svg-renderer.test.ts +47 -0
- package/src/export.ts +70 -0
- package/src/graph/canvas-renderer.ts +54 -0
- 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 +161 -1
- package/src/index.ts +2 -2
- package/src/mount.ts +21 -7
- package/src/svg-renderer.ts +95 -0
- package/src/table-renderer.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @opendata-ai/openchart-vanilla
|
|
2
|
+
|
|
3
|
+
DOM rendering adapter for OpenChart. Creates SVG charts, HTML tables, and canvas-based network graphs from compiled specs. Framework-agnostic.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @opendata-ai/openchart-vanilla
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You typically don't install this directly unless you're working without a framework. The framework packages (`openchart-react`, `openchart-vue`, `openchart-svelte`) include it as a dependency and wrap it with lifecycle management.
|
|
12
|
+
|
|
13
|
+
## Charts
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { createChart } from '@opendata-ai/openchart-vanilla';
|
|
17
|
+
|
|
18
|
+
const chart = createChart(container, spec, {
|
|
19
|
+
darkMode: 'auto',
|
|
20
|
+
responsive: true,
|
|
21
|
+
onMarkClick: (event) => console.log(event.datum),
|
|
22
|
+
onMarkHover: (event) => showTooltip(event),
|
|
23
|
+
onMarkLeave: () => hideTooltip(),
|
|
24
|
+
onLegendToggle: (series, visible) => console.log(series, visible),
|
|
25
|
+
onAnnotationClick: (annotation, event) => console.log(annotation),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
chart.update(newSpec); // Re-render with new spec
|
|
29
|
+
chart.resize(); // Manual resize trigger
|
|
30
|
+
chart.export('svg'); // Export as SVG string
|
|
31
|
+
await chart.export('png'); // Export as PNG Blob
|
|
32
|
+
chart.export('csv'); // Export data as CSV
|
|
33
|
+
chart.destroy(); // Clean up DOM and observers
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Responsive mode (default) uses a `ResizeObserver` on the container. Charts recompile at new dimensions automatically.
|
|
37
|
+
|
|
38
|
+
## Tables
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { createTable } from '@opendata-ai/openchart-vanilla';
|
|
42
|
+
|
|
43
|
+
const table = createTable(container, tableSpec, {
|
|
44
|
+
responsive: true,
|
|
45
|
+
onRowClick: (row) => console.log(row),
|
|
46
|
+
onStateChange: (state) => console.log(state.sort, state.search, state.page),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
table.update(newSpec);
|
|
50
|
+
table.getState(); // { sort, search, page }
|
|
51
|
+
table.setState({ page: 2 }); // Programmatic state control
|
|
52
|
+
table.export('csv'); // CSV export (respects sort/search, ignores pagination)
|
|
53
|
+
table.destroy();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Graphs
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { createGraph } from '@opendata-ai/openchart-vanilla';
|
|
60
|
+
|
|
61
|
+
const graph = createGraph(container, graphSpec, {
|
|
62
|
+
darkMode: 'auto',
|
|
63
|
+
responsive: true,
|
|
64
|
+
onNodeClick: (node) => console.log(node),
|
|
65
|
+
onNodeDoubleClick: (node) => console.log(node),
|
|
66
|
+
onSelectionChange: (nodeIds) => console.log(nodeIds),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
graph.search('query'); // Highlight matching nodes
|
|
70
|
+
graph.clearSearch();
|
|
71
|
+
graph.zoomToFit(); // Fit all nodes in viewport
|
|
72
|
+
graph.zoomToNode('node-id'); // Center on a specific node
|
|
73
|
+
graph.selectNode('node-id'); // Programmatic selection
|
|
74
|
+
graph.getSelectedNodes(); // Get selected node IDs
|
|
75
|
+
graph.update(newSpec);
|
|
76
|
+
graph.destroy();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Graphs render on canvas with a force simulation running in a web worker. Nodes support click, drag, and double-click. The simulation auto-fits nodes once it settles.
|
|
80
|
+
|
|
81
|
+
## Export utilities
|
|
82
|
+
|
|
83
|
+
Standalone export functions if you need them outside of an instance:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { exportSVG, exportPNG, exportCSV } from '@opendata-ai/openchart-vanilla';
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Other exports
|
|
90
|
+
|
|
91
|
+
- `observeResize()` - ResizeObserver wrapper for container tracking
|
|
92
|
+
- `attachKeyboardNav()` - Keyboard navigation for chart marks
|
|
93
|
+
- `createTooltipManager()` - Tooltip lifecycle management
|
|
94
|
+
- `renderChartSVG()` - Low-level SVG rendering from a ChartLayout
|
|
95
|
+
- `renderTable()` - Low-level table DOM rendering from a TableLayout
|
|
96
|
+
- `renderCell()` and cell-type renderers (`renderBarCell`, `renderSparklineCell`, `renderHeatmapCell`, etc.) - Individual table cell renderers
|
|
97
|
+
|
|
98
|
+
## Related docs
|
|
99
|
+
|
|
100
|
+
- [Getting started](../../docs/getting-started.md) for a hands-on tutorial
|
|
101
|
+
- [Integration guide](../../docs/integration-guide.md) for lifecycle management and events
|
|
102
|
+
- [Spec reference](../../docs/spec-reference.md) for field-by-field type details
|
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
|
*
|
|
@@ -74,10 +90,14 @@ interface GraphMountOptions {
|
|
|
74
90
|
responsive?: boolean;
|
|
75
91
|
onNodeClick?: (node: Record<string, unknown>) => void;
|
|
76
92
|
onNodeDoubleClick?: (node: Record<string, unknown>) => void;
|
|
93
|
+
onNodeHover?: (node: Record<string, unknown> | null) => void;
|
|
94
|
+
onEdgeHover?: (edge: Record<string, unknown> | null) => void;
|
|
77
95
|
onSelectionChange?: (nodeIds: string[]) => void;
|
|
78
96
|
}
|
|
79
97
|
interface GraphInstance {
|
|
80
98
|
update(spec: GraphSpec): void;
|
|
99
|
+
/** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */
|
|
100
|
+
updateVisuals(spec: GraphSpec): void;
|
|
81
101
|
search(query: string): void;
|
|
82
102
|
clearSearch(): void;
|
|
83
103
|
zoomToFit(): void;
|
|
@@ -116,7 +136,7 @@ interface MountOptions extends ChartEventHandlers {
|
|
|
116
136
|
/** Enable responsive resizing. Defaults to true. */
|
|
117
137
|
responsive?: boolean;
|
|
118
138
|
}
|
|
119
|
-
interface ExportOptions extends
|
|
139
|
+
interface ExportOptions extends JPGExportOptions {
|
|
120
140
|
}
|
|
121
141
|
interface ChartInstance {
|
|
122
142
|
/** Re-compile and re-render with a new spec. */
|
|
@@ -126,8 +146,9 @@ interface ChartInstance {
|
|
|
126
146
|
/** Export the chart. */
|
|
127
147
|
export(format: 'svg'): string;
|
|
128
148
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
149
|
+
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
129
150
|
export(format: 'csv'): string;
|
|
130
|
-
export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
151
|
+
export(format: 'svg' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
131
152
|
/** Remove all DOM elements and disconnect observers. */
|
|
132
153
|
destroy(): void;
|
|
133
154
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -324,4 +345,4 @@ interface TooltipManager {
|
|
|
324
345
|
*/
|
|
325
346
|
declare function createTooltipManager(container: HTMLElement): TooltipManager;
|
|
326
347
|
|
|
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 };
|
|
348
|
+
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]);
|
|
@@ -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,
|
|
@@ -169,7 +212,8 @@ var GraphCanvasRenderer = class {
|
|
|
169
212
|
visibleEdges,
|
|
170
213
|
hasActiveNode,
|
|
171
214
|
connectedNodeIds,
|
|
172
|
-
isGesturing ? null : searchMatches
|
|
215
|
+
isGesturing ? null : searchMatches,
|
|
216
|
+
hoveredEdgeId
|
|
173
217
|
);
|
|
174
218
|
this.drawNodesBatched(
|
|
175
219
|
ctx,
|
|
@@ -194,15 +238,41 @@ var GraphCanvasRenderer = class {
|
|
|
194
238
|
);
|
|
195
239
|
}
|
|
196
240
|
ctx.restore();
|
|
241
|
+
this.drawBrand(ctx, cssWidth, cssHeight, theme);
|
|
242
|
+
}
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
// Brand rendering
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
drawBrand(ctx, w, h, theme) {
|
|
247
|
+
if (w < 120) return;
|
|
248
|
+
const { dpr } = this;
|
|
249
|
+
ctx.save();
|
|
250
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
251
|
+
const padding = theme.spacing.padding;
|
|
252
|
+
const x = w - padding;
|
|
253
|
+
const y = h - 4;
|
|
254
|
+
ctx.font = `600 20px ${theme.fonts.family}`;
|
|
255
|
+
ctx.fillStyle = theme.colors.axis;
|
|
256
|
+
ctx.globalAlpha = 0.5;
|
|
257
|
+
ctx.textAlign = "right";
|
|
258
|
+
ctx.textBaseline = "alphabetic";
|
|
259
|
+
ctx.fillText("OpenData", x, y);
|
|
260
|
+
ctx.restore();
|
|
197
261
|
}
|
|
198
262
|
// -------------------------------------------------------------------------
|
|
199
263
|
// Batched edge drawing
|
|
200
264
|
// -------------------------------------------------------------------------
|
|
201
|
-
drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches) {
|
|
265
|
+
drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches, hoveredEdgeId) {
|
|
202
266
|
const dimmedEdges = [];
|
|
203
267
|
const defaultEdges = [];
|
|
204
268
|
const connectedEdges = [];
|
|
269
|
+
let hoveredEdge = null;
|
|
205
270
|
for (const edge of edges) {
|
|
271
|
+
const edgeId = `${edge.source}->${edge.target}`;
|
|
272
|
+
if (edgeId === hoveredEdgeId) {
|
|
273
|
+
hoveredEdge = edge;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
206
276
|
const isConnected = hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
|
|
207
277
|
const isDimmed = hasActiveNode && !isConnected;
|
|
208
278
|
if (isConnected) {
|
|
@@ -216,6 +286,19 @@ var GraphCanvasRenderer = class {
|
|
|
216
286
|
this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
|
|
217
287
|
this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
|
|
218
288
|
this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
|
|
289
|
+
if (hoveredEdge) {
|
|
290
|
+
const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
|
|
291
|
+
ctx.setLineDash(dash);
|
|
292
|
+
ctx.strokeStyle = hoveredEdge.stroke;
|
|
293
|
+
ctx.lineWidth = hoveredEdge.strokeWidth * 2;
|
|
294
|
+
ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
|
|
295
|
+
ctx.beginPath();
|
|
296
|
+
ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
|
|
297
|
+
ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
|
|
298
|
+
ctx.stroke();
|
|
299
|
+
ctx.setLineDash([]);
|
|
300
|
+
ctx.globalAlpha = 1;
|
|
301
|
+
}
|
|
219
302
|
}
|
|
220
303
|
/**
|
|
221
304
|
* Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
|
|
@@ -698,6 +781,10 @@ var GraphInteractionManager = class {
|
|
|
698
781
|
}
|
|
699
782
|
const hitId = this.hitTest(x, y);
|
|
700
783
|
this.callbacks.onHoverChange(hitId);
|
|
784
|
+
if (!hitId) {
|
|
785
|
+
const graph = this.transform.screenToGraph(x, y);
|
|
786
|
+
this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
|
|
787
|
+
}
|
|
701
788
|
this.canvas.style.cursor = hitId ? "pointer" : "default";
|
|
702
789
|
}
|
|
703
790
|
onMouseUp(e) {
|
|
@@ -1196,13 +1283,18 @@ var SimulationManager = class _SimulationManager {
|
|
|
1196
1283
|
community: n.community
|
|
1197
1284
|
}));
|
|
1198
1285
|
this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1286
|
+
const linkForce = forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
|
|
1287
|
+
if (config.linkStrength != null) {
|
|
1288
|
+
linkForce.strength(config.linkStrength);
|
|
1289
|
+
}
|
|
1290
|
+
const padding = config.collisionPadding ?? 2;
|
|
1291
|
+
this.syncSim = forceSimulation(this.syncNodes).force("link", linkForce).force("charge", forceManyBody().strength(config.chargeStrength)).force(
|
|
1203
1292
|
"collide",
|
|
1204
|
-
forceCollide().radius((d) => d.radius +
|
|
1293
|
+
forceCollide().radius((d) => d.radius + padding)
|
|
1205
1294
|
).force("gravityX", forceX(0).strength(0.05)).force("gravityY", forceY(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
|
|
1295
|
+
if (config.centerForce !== false) {
|
|
1296
|
+
this.syncSim.force("center", forceCenter(0, 0));
|
|
1297
|
+
}
|
|
1206
1298
|
if (config.clustering) {
|
|
1207
1299
|
const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
|
|
1208
1300
|
this.syncSim.force("cluster", clusterFn);
|
|
@@ -1447,6 +1539,7 @@ function createGraph(container, spec, options) {
|
|
|
1447
1539
|
let positionedEdges = [];
|
|
1448
1540
|
let adjacencyMap = /* @__PURE__ */ new Map();
|
|
1449
1541
|
let hoveredNodeId = null;
|
|
1542
|
+
let hoveredEdgeId = null;
|
|
1450
1543
|
let selectedNodeIds = /* @__PURE__ */ new Set();
|
|
1451
1544
|
let animFrameId = null;
|
|
1452
1545
|
let needsRender = false;
|
|
@@ -1507,6 +1600,38 @@ function createGraph(container, spec, options) {
|
|
|
1507
1600
|
const node = compilation.nodes.find((n) => n.id === nodeId);
|
|
1508
1601
|
return node?.data ?? {};
|
|
1509
1602
|
}
|
|
1603
|
+
function pointToSegmentDist(px, py, ax, ay, bx, by) {
|
|
1604
|
+
const dx = bx - ax;
|
|
1605
|
+
const dy = by - ay;
|
|
1606
|
+
const lenSq = dx * dx + dy * dy;
|
|
1607
|
+
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
|
|
1608
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
1609
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
1610
|
+
}
|
|
1611
|
+
function hitTestEdge(graphX, graphY, threshold) {
|
|
1612
|
+
let bestDist = threshold;
|
|
1613
|
+
let bestEdgeId = null;
|
|
1614
|
+
for (const edge of positionedEdges) {
|
|
1615
|
+
const dist = pointToSegmentDist(
|
|
1616
|
+
graphX,
|
|
1617
|
+
graphY,
|
|
1618
|
+
edge.sourceX,
|
|
1619
|
+
edge.sourceY,
|
|
1620
|
+
edge.targetX,
|
|
1621
|
+
edge.targetY
|
|
1622
|
+
);
|
|
1623
|
+
if (dist < bestDist) {
|
|
1624
|
+
bestDist = dist;
|
|
1625
|
+
bestEdgeId = `${edge.source}->${edge.target}`;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return bestEdgeId;
|
|
1629
|
+
}
|
|
1630
|
+
function edgeDataById(edgeId) {
|
|
1631
|
+
const [source, target] = edgeId.split("->");
|
|
1632
|
+
const edge = compilation.edges.find((e) => e.source === source && e.target === target);
|
|
1633
|
+
return edge?.data ?? null;
|
|
1634
|
+
}
|
|
1510
1635
|
function createDOM() {
|
|
1511
1636
|
const { width, height } = getContainerDimensions();
|
|
1512
1637
|
const isDark = resolveDarkMode(options?.darkMode);
|
|
@@ -1581,7 +1706,10 @@ function createGraph(container, spec, options) {
|
|
|
1581
1706
|
clustering: config.clustering,
|
|
1582
1707
|
alphaDecay: config.alphaDecay,
|
|
1583
1708
|
velocityDecay: config.velocityDecay,
|
|
1584
|
-
collisionRadius: config.collisionRadius
|
|
1709
|
+
collisionRadius: config.collisionRadius,
|
|
1710
|
+
collisionPadding: config.collisionPadding,
|
|
1711
|
+
linkStrength: config.linkStrength,
|
|
1712
|
+
centerForce: config.centerForce
|
|
1585
1713
|
});
|
|
1586
1714
|
simulation.onTick((positions, _alpha) => {
|
|
1587
1715
|
if (destroyed) return;
|
|
@@ -1641,6 +1769,7 @@ function createGraph(container, spec, options) {
|
|
|
1641
1769
|
edges: positionedEdges,
|
|
1642
1770
|
transform: { x: transform.x, y: transform.y, k: transform.k },
|
|
1643
1771
|
hoveredNodeId,
|
|
1772
|
+
hoveredEdgeId,
|
|
1644
1773
|
selectedNodeIds,
|
|
1645
1774
|
adjacencyMap,
|
|
1646
1775
|
theme: compilation.theme,
|
|
@@ -1663,7 +1792,16 @@ function createGraph(container, spec, options) {
|
|
|
1663
1792
|
hoveredNodeId = nodeId;
|
|
1664
1793
|
needsRender = true;
|
|
1665
1794
|
scheduleRender();
|
|
1795
|
+
if (nodeId) {
|
|
1796
|
+
options?.onNodeHover?.(nodeDataById(nodeId));
|
|
1797
|
+
} else {
|
|
1798
|
+
options?.onNodeHover?.(null);
|
|
1799
|
+
}
|
|
1666
1800
|
if (nodeId && tooltipManager) {
|
|
1801
|
+
if (hoveredEdgeId) {
|
|
1802
|
+
hoveredEdgeId = null;
|
|
1803
|
+
options?.onEdgeHover?.(null);
|
|
1804
|
+
}
|
|
1667
1805
|
const content = compilation.tooltipDescriptors.get(nodeId);
|
|
1668
1806
|
if (content) {
|
|
1669
1807
|
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
@@ -1672,10 +1810,35 @@ function createGraph(container, spec, options) {
|
|
|
1672
1810
|
tooltipManager.show(content, screen.x, screen.y);
|
|
1673
1811
|
}
|
|
1674
1812
|
}
|
|
1675
|
-
} else {
|
|
1813
|
+
} else if (!nodeId) {
|
|
1676
1814
|
tooltipManager?.hide();
|
|
1677
1815
|
}
|
|
1678
1816
|
},
|
|
1817
|
+
onBackgroundHover(graphX, graphY, screenX, screenY) {
|
|
1818
|
+
const transform = interactionManager?.getTransform();
|
|
1819
|
+
const threshold = 5 / (transform?.k ?? 1);
|
|
1820
|
+
const edgeId = hitTestEdge(graphX, graphY, threshold);
|
|
1821
|
+
if (edgeId !== hoveredEdgeId) {
|
|
1822
|
+
hoveredEdgeId = edgeId;
|
|
1823
|
+
needsRender = true;
|
|
1824
|
+
scheduleRender();
|
|
1825
|
+
if (edgeId) {
|
|
1826
|
+
const data = edgeDataById(edgeId);
|
|
1827
|
+
options?.onEdgeHover?.(data);
|
|
1828
|
+
if (tooltipManager && data) {
|
|
1829
|
+
const fields = Object.entries(data).filter(([key]) => key !== "source" && key !== "target").filter(([, value]) => value != null).map(([key, value]) => ({
|
|
1830
|
+
label: key,
|
|
1831
|
+
value: typeof value === "number" ? value.toLocaleString() : String(value)
|
|
1832
|
+
}));
|
|
1833
|
+
const [source, target] = edgeId.split("->");
|
|
1834
|
+
tooltipManager.show({ title: `${source} \u2192 ${target}`, fields }, screenX, screenY);
|
|
1835
|
+
}
|
|
1836
|
+
} else {
|
|
1837
|
+
options?.onEdgeHover?.(null);
|
|
1838
|
+
tooltipManager?.hide();
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1679
1842
|
onSelectionChange(nodeIds) {
|
|
1680
1843
|
selectedNodeIds = new Set(nodeIds);
|
|
1681
1844
|
needsRender = true;
|
|
@@ -1801,9 +1964,40 @@ function createGraph(container, spec, options) {
|
|
|
1801
1964
|
initSimulation();
|
|
1802
1965
|
initInteraction();
|
|
1803
1966
|
hoveredNodeId = null;
|
|
1967
|
+
hoveredEdgeId = null;
|
|
1804
1968
|
selectedNodeIds = /* @__PURE__ */ new Set();
|
|
1805
1969
|
searchManager.clearSearch();
|
|
1806
1970
|
}
|
|
1971
|
+
function updateVisuals(newSpec) {
|
|
1972
|
+
if (destroyed) return;
|
|
1973
|
+
currentSpec = newSpec;
|
|
1974
|
+
const posMap = /* @__PURE__ */ new Map();
|
|
1975
|
+
for (const node of positionedNodes) {
|
|
1976
|
+
posMap.set(node.id, { x: node.x, y: node.y });
|
|
1977
|
+
}
|
|
1978
|
+
compilation = compile();
|
|
1979
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
1980
|
+
positionedNodes = compilation.nodes.map((node) => {
|
|
1981
|
+
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
|
1982
|
+
return { ...node, x: pos.x, y: pos.y };
|
|
1983
|
+
});
|
|
1984
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
1985
|
+
const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
|
|
1986
|
+
const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
|
|
1987
|
+
return {
|
|
1988
|
+
...edge,
|
|
1989
|
+
sourceX: src.x,
|
|
1990
|
+
sourceY: src.y,
|
|
1991
|
+
targetX: tgt.x,
|
|
1992
|
+
targetY: tgt.y
|
|
1993
|
+
};
|
|
1994
|
+
});
|
|
1995
|
+
spatialIndex.rebuild(positionedNodes);
|
|
1996
|
+
renderChrome2();
|
|
1997
|
+
renderLegend2();
|
|
1998
|
+
needsRender = true;
|
|
1999
|
+
scheduleRender();
|
|
2000
|
+
}
|
|
1807
2001
|
function teardownSubsystems() {
|
|
1808
2002
|
if (animFrameId !== null) {
|
|
1809
2003
|
cancelAnimationFrame(animFrameId);
|
|
@@ -1853,6 +2047,8 @@ function createGraph(container, spec, options) {
|
|
|
1853
2047
|
return {
|
|
1854
2048
|
update() {
|
|
1855
2049
|
},
|
|
2050
|
+
updateVisuals() {
|
|
2051
|
+
},
|
|
1856
2052
|
search() {
|
|
1857
2053
|
},
|
|
1858
2054
|
clearSearch() {
|
|
@@ -1877,6 +2073,7 @@ function createGraph(container, spec, options) {
|
|
|
1877
2073
|
}
|
|
1878
2074
|
return {
|
|
1879
2075
|
update,
|
|
2076
|
+
updateVisuals,
|
|
1880
2077
|
search,
|
|
1881
2078
|
clearSearch,
|
|
1882
2079
|
zoomToFit,
|
|
@@ -2521,6 +2718,73 @@ function renderLegend(parent, legend) {
|
|
|
2521
2718
|
}
|
|
2522
2719
|
parent.appendChild(g);
|
|
2523
2720
|
}
|
|
2721
|
+
var BRAND_FONT_SIZE = 20;
|
|
2722
|
+
var BRAND_MIN_WIDTH = 120;
|
|
2723
|
+
var BRAND_URL = "https://tryopendata.ai";
|
|
2724
|
+
var XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
2725
|
+
function brandPosition(layout) {
|
|
2726
|
+
const { width } = layout.dimensions;
|
|
2727
|
+
const padding = layout.theme.spacing.padding;
|
|
2728
|
+
const rightEdge = width - padding;
|
|
2729
|
+
const { chrome } = layout;
|
|
2730
|
+
const xAxisExtent = layout.axes.x ? layout.axes.x.label ? 48 : 26 : 0;
|
|
2731
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
2732
|
+
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
2733
|
+
const chromeY = firstBottom ? bottomOffset + firstBottom.y : bottomOffset + layout.theme.spacing.chartToFooter;
|
|
2734
|
+
const y = chromeY + BRAND_FONT_SIZE;
|
|
2735
|
+
const dataWidth = estimateTextWidth("Data", BRAND_FONT_SIZE, 600);
|
|
2736
|
+
const dataX = rightEdge - dataWidth;
|
|
2737
|
+
const openX = dataX;
|
|
2738
|
+
return { openX, dataX, y, fill: layout.theme.colors.axis };
|
|
2739
|
+
}
|
|
2740
|
+
function renderBrandOpen(parent, layout) {
|
|
2741
|
+
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
2742
|
+
const { openX, y, fill } = brandPosition(layout);
|
|
2743
|
+
const a = createSVGElement("a");
|
|
2744
|
+
a.setAttribute("href", BRAND_URL);
|
|
2745
|
+
a.setAttributeNS(XLINK_NS, "xlink:href", BRAND_URL);
|
|
2746
|
+
a.setAttribute("target", "_blank");
|
|
2747
|
+
a.setAttribute("rel", "noopener");
|
|
2748
|
+
a.setAttribute("class", "viz-axis-ref");
|
|
2749
|
+
const text = createSVGElement("text");
|
|
2750
|
+
setAttrs(text, {
|
|
2751
|
+
x: openX,
|
|
2752
|
+
y,
|
|
2753
|
+
"font-family": layout.theme.fonts.family,
|
|
2754
|
+
"font-size": BRAND_FONT_SIZE,
|
|
2755
|
+
"font-weight": 500,
|
|
2756
|
+
"text-anchor": "end",
|
|
2757
|
+
"fill-opacity": 0.55
|
|
2758
|
+
});
|
|
2759
|
+
text.style.setProperty("fill", fill);
|
|
2760
|
+
text.textContent = "Open";
|
|
2761
|
+
a.appendChild(text);
|
|
2762
|
+
parent.appendChild(a);
|
|
2763
|
+
}
|
|
2764
|
+
function renderBrandData(parent, layout) {
|
|
2765
|
+
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
2766
|
+
const { dataX, y, fill } = brandPosition(layout);
|
|
2767
|
+
const a = createSVGElement("a");
|
|
2768
|
+
a.setAttribute("href", BRAND_URL);
|
|
2769
|
+
a.setAttributeNS(XLINK_NS, "xlink:href", BRAND_URL);
|
|
2770
|
+
a.setAttribute("target", "_blank");
|
|
2771
|
+
a.setAttribute("rel", "noopener");
|
|
2772
|
+
a.setAttribute("class", "viz-chrome-ref");
|
|
2773
|
+
const text = createSVGElement("text");
|
|
2774
|
+
setAttrs(text, {
|
|
2775
|
+
x: dataX,
|
|
2776
|
+
y,
|
|
2777
|
+
"font-family": layout.theme.fonts.family,
|
|
2778
|
+
"font-size": BRAND_FONT_SIZE,
|
|
2779
|
+
"font-weight": 600,
|
|
2780
|
+
"text-anchor": "start",
|
|
2781
|
+
"fill-opacity": 0.55
|
|
2782
|
+
});
|
|
2783
|
+
text.style.setProperty("fill", fill);
|
|
2784
|
+
text.textContent = "Data";
|
|
2785
|
+
a.appendChild(text);
|
|
2786
|
+
parent.appendChild(a);
|
|
2787
|
+
}
|
|
2524
2788
|
function renderChartSVG(layout, container) {
|
|
2525
2789
|
const { width, height } = layout.dimensions;
|
|
2526
2790
|
const svg = createSVGElement("svg");
|
|
@@ -2561,7 +2825,9 @@ function renderChartSVG(layout, container) {
|
|
|
2561
2825
|
svg.appendChild(clippedGroup);
|
|
2562
2826
|
renderAnnotations(svg, layout);
|
|
2563
2827
|
renderLegend(svg, layout.legend);
|
|
2828
|
+
renderBrandOpen(svg, layout);
|
|
2564
2829
|
renderChrome(svg, layout);
|
|
2830
|
+
renderBrandData(svg, layout);
|
|
2565
2831
|
container.appendChild(svg);
|
|
2566
2832
|
return svg;
|
|
2567
2833
|
}
|
|
@@ -3254,7 +3520,7 @@ function wireSeriesLabelDrag(svg, spec, onEdit, setDragging) {
|
|
|
3254
3520
|
}
|
|
3255
3521
|
};
|
|
3256
3522
|
}
|
|
3257
|
-
function wireLegendInteraction(svg, _layout, onLegendToggle) {
|
|
3523
|
+
function wireLegendInteraction(svg, _layout, onLegendToggle, onEdit) {
|
|
3258
3524
|
const legendEntries = svg.querySelectorAll("[data-legend-index]");
|
|
3259
3525
|
const cleanups = [];
|
|
3260
3526
|
const hiddenSeries = /* @__PURE__ */ new Set();
|
|
@@ -3267,11 +3533,13 @@ function wireLegendInteraction(svg, _layout, onLegendToggle) {
|
|
|
3267
3533
|
entry.setAttribute("opacity", "1");
|
|
3268
3534
|
entry.setAttribute("aria-label", `${label}: visible`);
|
|
3269
3535
|
onLegendToggle?.(label, true);
|
|
3536
|
+
onEdit?.({ type: "legend-toggle", series: label, hidden: false });
|
|
3270
3537
|
} else {
|
|
3271
3538
|
hiddenSeries.add(label);
|
|
3272
3539
|
entry.setAttribute("opacity", "0.3");
|
|
3273
3540
|
entry.setAttribute("aria-label", `${label}: hidden`);
|
|
3274
3541
|
onLegendToggle?.(label, false);
|
|
3542
|
+
onEdit?.({ type: "legend-toggle", series: label, hidden: true });
|
|
3275
3543
|
}
|
|
3276
3544
|
const marks = svg.querySelectorAll(".viz-mark");
|
|
3277
3545
|
for (const mark of marks) {
|
|
@@ -3504,7 +3772,12 @@ function createChart(container, spec, options) {
|
|
|
3504
3772
|
tooltipManager,
|
|
3505
3773
|
currentLayout
|
|
3506
3774
|
);
|
|
3507
|
-
cleanupLegend = wireLegendInteraction(
|
|
3775
|
+
cleanupLegend = wireLegendInteraction(
|
|
3776
|
+
svgElement,
|
|
3777
|
+
currentLayout,
|
|
3778
|
+
options?.onLegendToggle,
|
|
3779
|
+
options?.onEdit
|
|
3780
|
+
);
|
|
3508
3781
|
if (options?.onMarkClick || options?.onMarkHover || options?.onMarkLeave || options?.onAnnotationClick) {
|
|
3509
3782
|
const specAnnotations = "annotations" in currentSpec && Array.isArray(currentSpec.annotations) ? currentSpec.annotations : [];
|
|
3510
3783
|
cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
|
|
@@ -3570,6 +3843,8 @@ function createChart(container, spec, options) {
|
|
|
3570
3843
|
return exportSVG(svgElement);
|
|
3571
3844
|
case "png":
|
|
3572
3845
|
return exportPNG(svgElement, exportOptions);
|
|
3846
|
+
case "jpg":
|
|
3847
|
+
return exportJPG(svgElement, exportOptions);
|
|
3573
3848
|
case "csv":
|
|
3574
3849
|
return exportCSV(
|
|
3575
3850
|
"data" in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : []
|
|
@@ -4371,6 +4646,18 @@ function renderTable(layout, container) {
|
|
|
4371
4646
|
liveRegion.setAttribute("aria-atomic", "true");
|
|
4372
4647
|
liveRegion.setAttribute("role", "status");
|
|
4373
4648
|
wrapper.appendChild(liveRegion);
|
|
4649
|
+
const brandColor = theme ? theme.colors.axis : "#999999";
|
|
4650
|
+
const brand = document.createElement("div");
|
|
4651
|
+
brand.className = "viz-table-ref";
|
|
4652
|
+
brand.style.cssText = "text-align: right; padding: 4px 8px;";
|
|
4653
|
+
const brandLink = document.createElement("a");
|
|
4654
|
+
brandLink.href = "https://tryopendata.ai";
|
|
4655
|
+
brandLink.target = "_blank";
|
|
4656
|
+
brandLink.rel = "noopener";
|
|
4657
|
+
brandLink.style.cssText = `font-size: 20px; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : "sans-serif"};`;
|
|
4658
|
+
brandLink.textContent = "OpenData";
|
|
4659
|
+
brand.appendChild(brandLink);
|
|
4660
|
+
wrapper.appendChild(brand);
|
|
4374
4661
|
container.appendChild(wrapper);
|
|
4375
4662
|
return wrapper;
|
|
4376
4663
|
}
|
|
@@ -4727,6 +5014,7 @@ export {
|
|
|
4727
5014
|
createTable,
|
|
4728
5015
|
createTooltipManager,
|
|
4729
5016
|
exportCSV,
|
|
5017
|
+
exportJPG,
|
|
4730
5018
|
exportPNG,
|
|
4731
5019
|
exportSVG,
|
|
4732
5020
|
observeResize,
|