@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/src/graph-mount.ts
CHANGED
|
@@ -35,13 +35,21 @@ export interface GraphMountOptions {
|
|
|
35
35
|
theme?: ThemeConfig;
|
|
36
36
|
darkMode?: DarkMode;
|
|
37
37
|
responsive?: boolean;
|
|
38
|
+
/** Show the built-in tooltip on node/edge hover. Defaults to true. */
|
|
39
|
+
tooltip?: boolean;
|
|
40
|
+
/** Show the built-in legend. Defaults to true. */
|
|
41
|
+
legend?: boolean;
|
|
38
42
|
onNodeClick?: (node: Record<string, unknown>) => void;
|
|
39
43
|
onNodeDoubleClick?: (node: Record<string, unknown>) => void;
|
|
44
|
+
onNodeHover?: (node: Record<string, unknown> | null) => void;
|
|
45
|
+
onEdgeHover?: (edge: Record<string, unknown> | null) => void;
|
|
40
46
|
onSelectionChange?: (nodeIds: string[]) => void;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
export interface GraphInstance {
|
|
44
50
|
update(spec: GraphSpec): void;
|
|
51
|
+
/** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */
|
|
52
|
+
updateVisuals(spec: GraphSpec): void;
|
|
45
53
|
search(query: string): void;
|
|
46
54
|
clearSearch(): void;
|
|
47
55
|
zoomToFit(): void;
|
|
@@ -107,6 +115,7 @@ export function createGraph(
|
|
|
107
115
|
let positionedEdges: PositionedEdge[] = [];
|
|
108
116
|
let adjacencyMap = new Map<string, Set<string>>();
|
|
109
117
|
let hoveredNodeId: string | null = null;
|
|
118
|
+
let hoveredEdgeId: string | null = null;
|
|
110
119
|
let selectedNodeIds = new Set<string>();
|
|
111
120
|
let animFrameId: number | null = null;
|
|
112
121
|
let needsRender = false;
|
|
@@ -185,6 +194,61 @@ export function createGraph(
|
|
|
185
194
|
return node?.data ?? {};
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Point-to-line-segment distance for edge hit testing.
|
|
199
|
+
* Returns the shortest distance from point (px, py) to the segment (ax, ay)-(bx, by).
|
|
200
|
+
*/
|
|
201
|
+
function pointToSegmentDist(
|
|
202
|
+
px: number,
|
|
203
|
+
py: number,
|
|
204
|
+
ax: number,
|
|
205
|
+
ay: number,
|
|
206
|
+
bx: number,
|
|
207
|
+
by: number,
|
|
208
|
+
): number {
|
|
209
|
+
const dx = bx - ax;
|
|
210
|
+
const dy = by - ay;
|
|
211
|
+
const lenSq = dx * dx + dy * dy;
|
|
212
|
+
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
|
|
213
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
214
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Find the edge closest to a graph-space point, within a threshold.
|
|
219
|
+
* Returns an edge key "source->target" or null.
|
|
220
|
+
*/
|
|
221
|
+
function hitTestEdge(graphX: number, graphY: number, threshold: number): string | null {
|
|
222
|
+
let bestDist = threshold;
|
|
223
|
+
let bestEdgeId: string | null = null;
|
|
224
|
+
|
|
225
|
+
for (const edge of positionedEdges) {
|
|
226
|
+
const dist = pointToSegmentDist(
|
|
227
|
+
graphX,
|
|
228
|
+
graphY,
|
|
229
|
+
edge.sourceX,
|
|
230
|
+
edge.sourceY,
|
|
231
|
+
edge.targetX,
|
|
232
|
+
edge.targetY,
|
|
233
|
+
);
|
|
234
|
+
if (dist < bestDist) {
|
|
235
|
+
bestDist = dist;
|
|
236
|
+
bestEdgeId = `${edge.source}->${edge.target}`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return bestEdgeId;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Look up edge data by edge id ("source->target").
|
|
245
|
+
*/
|
|
246
|
+
function edgeDataById(edgeId: string): Record<string, unknown> | null {
|
|
247
|
+
const [source, target] = edgeId.split('->');
|
|
248
|
+
const edge = compilation.edges.find((e) => e.source === source && e.target === target);
|
|
249
|
+
return edge?.data ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
188
252
|
// ---------------------------------------------------------------------------
|
|
189
253
|
// DOM creation
|
|
190
254
|
// ---------------------------------------------------------------------------
|
|
@@ -218,10 +282,12 @@ export function createGraph(
|
|
|
218
282
|
wrapper.appendChild(canvas);
|
|
219
283
|
|
|
220
284
|
// Legend
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
285
|
+
if (options?.legend !== false) {
|
|
286
|
+
legendEl = document.createElement('div');
|
|
287
|
+
legendEl.className = 'viz-graph-legend';
|
|
288
|
+
renderLegend();
|
|
289
|
+
wrapper.appendChild(legendEl);
|
|
290
|
+
}
|
|
225
291
|
|
|
226
292
|
container.appendChild(wrapper);
|
|
227
293
|
|
|
@@ -289,6 +355,9 @@ export function createGraph(
|
|
|
289
355
|
alphaDecay: config.alphaDecay,
|
|
290
356
|
velocityDecay: config.velocityDecay,
|
|
291
357
|
collisionRadius: config.collisionRadius,
|
|
358
|
+
collisionPadding: config.collisionPadding,
|
|
359
|
+
linkStrength: config.linkStrength,
|
|
360
|
+
centerForce: config.centerForce,
|
|
292
361
|
});
|
|
293
362
|
|
|
294
363
|
simulation.onTick((positions, _alpha) => {
|
|
@@ -365,6 +434,7 @@ export function createGraph(
|
|
|
365
434
|
edges: positionedEdges,
|
|
366
435
|
transform: { x: transform.x, y: transform.y, k: transform.k },
|
|
367
436
|
hoveredNodeId,
|
|
437
|
+
hoveredEdgeId,
|
|
368
438
|
selectedNodeIds,
|
|
369
439
|
adjacencyMap,
|
|
370
440
|
theme: compilation.theme,
|
|
@@ -383,7 +453,9 @@ export function createGraph(
|
|
|
383
453
|
function initInteraction(): void {
|
|
384
454
|
if (!canvas) return;
|
|
385
455
|
|
|
386
|
-
|
|
456
|
+
if (options?.tooltip !== false) {
|
|
457
|
+
tooltipManager = createTooltipManager(wrapper!);
|
|
458
|
+
}
|
|
387
459
|
|
|
388
460
|
interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
|
|
389
461
|
onTransformChange(_transform) {
|
|
@@ -396,8 +468,20 @@ export function createGraph(
|
|
|
396
468
|
needsRender = true;
|
|
397
469
|
scheduleRender();
|
|
398
470
|
|
|
471
|
+
// Fire onNodeHover callback
|
|
472
|
+
if (nodeId) {
|
|
473
|
+
options?.onNodeHover?.(nodeDataById(nodeId));
|
|
474
|
+
} else {
|
|
475
|
+
options?.onNodeHover?.(null);
|
|
476
|
+
}
|
|
477
|
+
|
|
399
478
|
// Show or hide tooltip
|
|
400
479
|
if (nodeId && tooltipManager) {
|
|
480
|
+
// Clear edge hover when hovering a node
|
|
481
|
+
if (hoveredEdgeId) {
|
|
482
|
+
hoveredEdgeId = null;
|
|
483
|
+
options?.onEdgeHover?.(null);
|
|
484
|
+
}
|
|
401
485
|
const content = compilation.tooltipDescriptors.get(nodeId);
|
|
402
486
|
if (content) {
|
|
403
487
|
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
@@ -406,10 +490,46 @@ export function createGraph(
|
|
|
406
490
|
tooltipManager.show(content, screen.x, screen.y);
|
|
407
491
|
}
|
|
408
492
|
}
|
|
409
|
-
} else {
|
|
493
|
+
} else if (!nodeId) {
|
|
494
|
+
// Tooltip hiding handled in onBackgroundHover (edge may show tooltip)
|
|
495
|
+
// If no edge hover happens, tooltip stays hidden
|
|
410
496
|
tooltipManager?.hide();
|
|
411
497
|
}
|
|
412
498
|
},
|
|
499
|
+
onBackgroundHover(graphX, graphY, screenX, screenY) {
|
|
500
|
+
// Edge hit testing: check proximity to edge line segments
|
|
501
|
+
const transform = interactionManager?.getTransform();
|
|
502
|
+
const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
|
|
503
|
+
const edgeId = hitTestEdge(graphX, graphY, threshold);
|
|
504
|
+
|
|
505
|
+
if (edgeId !== hoveredEdgeId) {
|
|
506
|
+
hoveredEdgeId = edgeId;
|
|
507
|
+
needsRender = true;
|
|
508
|
+
scheduleRender();
|
|
509
|
+
|
|
510
|
+
if (edgeId) {
|
|
511
|
+
const data = edgeDataById(edgeId);
|
|
512
|
+
options?.onEdgeHover?.(data);
|
|
513
|
+
|
|
514
|
+
// Show edge tooltip
|
|
515
|
+
if (tooltipManager && data) {
|
|
516
|
+
const fields = Object.entries(data)
|
|
517
|
+
.filter(([key]) => key !== 'source' && key !== 'target')
|
|
518
|
+
.filter(([, value]) => value != null)
|
|
519
|
+
.map(([key, value]) => ({
|
|
520
|
+
label: key,
|
|
521
|
+
value: typeof value === 'number' ? value.toLocaleString() : String(value),
|
|
522
|
+
}));
|
|
523
|
+
|
|
524
|
+
const [source, target] = edgeId.split('->');
|
|
525
|
+
tooltipManager.show({ title: `${source} → ${target}`, fields }, screenX, screenY);
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
options?.onEdgeHover?.(null);
|
|
529
|
+
tooltipManager?.hide();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
},
|
|
413
533
|
onSelectionChange(nodeIds) {
|
|
414
534
|
selectedNodeIds = new Set(nodeIds);
|
|
415
535
|
needsRender = true;
|
|
@@ -567,10 +687,56 @@ export function createGraph(
|
|
|
567
687
|
|
|
568
688
|
// Reset state
|
|
569
689
|
hoveredNodeId = null;
|
|
690
|
+
hoveredEdgeId = null;
|
|
570
691
|
selectedNodeIds = new Set();
|
|
571
692
|
searchManager.clearSearch();
|
|
572
693
|
}
|
|
573
694
|
|
|
695
|
+
function updateVisuals(newSpec: GraphSpec): void {
|
|
696
|
+
if (destroyed) return;
|
|
697
|
+
currentSpec = newSpec;
|
|
698
|
+
|
|
699
|
+
// Build a position lookup from current positioned nodes
|
|
700
|
+
const posMap = new Map<string, { x: number; y: number }>();
|
|
701
|
+
for (const node of positionedNodes) {
|
|
702
|
+
posMap.set(node.id, { x: node.x, y: node.y });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
|
|
706
|
+
compilation = compile();
|
|
707
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
708
|
+
|
|
709
|
+
// Transfer positions to new compiled nodes
|
|
710
|
+
positionedNodes = compilation.nodes.map((node) => {
|
|
711
|
+
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
|
712
|
+
return { ...node, x: pos.x, y: pos.y };
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Rebuild positioned edges from existing positions
|
|
716
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
717
|
+
const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
|
|
718
|
+
const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
|
|
719
|
+
return {
|
|
720
|
+
...edge,
|
|
721
|
+
sourceX: src.x,
|
|
722
|
+
sourceY: src.y,
|
|
723
|
+
targetX: tgt.x,
|
|
724
|
+
targetY: tgt.y,
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Rebuild spatial index with updated visuals
|
|
729
|
+
spatialIndex.rebuild(positionedNodes);
|
|
730
|
+
|
|
731
|
+
// Update DOM chrome/legend
|
|
732
|
+
renderChrome();
|
|
733
|
+
renderLegend();
|
|
734
|
+
|
|
735
|
+
// Re-render canvas without restarting simulation
|
|
736
|
+
needsRender = true;
|
|
737
|
+
scheduleRender();
|
|
738
|
+
}
|
|
739
|
+
|
|
574
740
|
function teardownSubsystems(): void {
|
|
575
741
|
if (animFrameId !== null) {
|
|
576
742
|
cancelAnimationFrame(animFrameId);
|
|
@@ -631,6 +797,7 @@ export function createGraph(
|
|
|
631
797
|
// Return a no-op instance so callers don't crash
|
|
632
798
|
return {
|
|
633
799
|
update() {},
|
|
800
|
+
updateVisuals() {},
|
|
634
801
|
search() {},
|
|
635
802
|
clearSearch() {},
|
|
636
803
|
zoomToFit() {},
|
|
@@ -651,6 +818,7 @@ export function createGraph(
|
|
|
651
818
|
|
|
652
819
|
return {
|
|
653
820
|
update,
|
|
821
|
+
updateVisuals,
|
|
654
822
|
search,
|
|
655
823
|
clearSearch,
|
|
656
824
|
zoomToFit,
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,9 @@ export type {
|
|
|
17
17
|
TableSpec,
|
|
18
18
|
VizSpec,
|
|
19
19
|
} from '@opendata-ai/openchart-engine';
|
|
20
|
-
export type { PNGExportOptions } from './export';
|
|
20
|
+
export type { JPGExportOptions, PNGExportOptions } from './export';
|
|
21
21
|
// Export utilities
|
|
22
|
-
export { exportCSV, exportPNG, exportSVG } from './export';
|
|
22
|
+
export { exportCSV, exportJPG, exportPNG, exportSVG } from './export';
|
|
23
23
|
// Graph simulation worker
|
|
24
24
|
export { createSimulationWorker } from './graph/simulation-worker-url';
|
|
25
25
|
export type { GraphInstance, GraphMountOptions } from './graph-mount';
|
package/src/mount.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
TooltipContent,
|
|
27
27
|
} from '@opendata-ai/openchart-core';
|
|
28
28
|
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
29
|
-
import { exportCSV, exportPNG, exportSVG, type
|
|
29
|
+
import { exportCSV, exportJPG, exportPNG, exportSVG, type JPGExportOptions } from './export';
|
|
30
30
|
import { observeResize } from './resize-observer';
|
|
31
31
|
import { renderChartSVG } from './svg-renderer';
|
|
32
32
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
@@ -46,8 +46,8 @@ export interface MountOptions extends ChartEventHandlers {
|
|
|
46
46
|
responsive?: boolean;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export interface ExportOptions extends
|
|
50
|
-
// Extensible for future formats
|
|
49
|
+
export interface ExportOptions extends JPGExportOptions {
|
|
50
|
+
// Extensible for future formats (extends JPGExportOptions which extends PNGExportOptions)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface ChartInstance {
|
|
@@ -58,8 +58,9 @@ export interface ChartInstance {
|
|
|
58
58
|
/** Export the chart. */
|
|
59
59
|
export(format: 'svg'): string;
|
|
60
60
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
61
|
+
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
61
62
|
export(format: 'csv'): string;
|
|
62
|
-
export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
63
|
+
export(format: 'svg' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
63
64
|
/** Remove all DOM elements and disconnect observers. */
|
|
64
65
|
destroy(): void;
|
|
65
66
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -1096,13 +1097,16 @@ function wireSeriesLabelDrag(
|
|
|
1096
1097
|
|
|
1097
1098
|
/**
|
|
1098
1099
|
* Wire click handlers on legend entries to toggle series visibility.
|
|
1099
|
-
*
|
|
1100
|
+
* Fires onEdit with { type: 'legend-toggle', series, hidden } for each toggle,
|
|
1101
|
+
* and optionally calls the legacy onLegendToggle callback.
|
|
1102
|
+
* Legend entries for hidden series stay visible but dimmed (opacity 0.3).
|
|
1100
1103
|
* Returns a cleanup function.
|
|
1101
1104
|
*/
|
|
1102
1105
|
function wireLegendInteraction(
|
|
1103
1106
|
svg: SVGElement,
|
|
1104
1107
|
_layout: ChartLayout,
|
|
1105
1108
|
onLegendToggle?: (series: string, visible: boolean) => void,
|
|
1109
|
+
onEdit?: (edit: ElementEdit) => void,
|
|
1106
1110
|
): () => void {
|
|
1107
1111
|
const legendEntries = svg.querySelectorAll('[data-legend-index]');
|
|
1108
1112
|
const cleanups: Array<() => void> = [];
|
|
@@ -1120,11 +1124,13 @@ function wireLegendInteraction(
|
|
|
1120
1124
|
entry.setAttribute('opacity', '1');
|
|
1121
1125
|
entry.setAttribute('aria-label', `${label}: visible`);
|
|
1122
1126
|
onLegendToggle?.(label, true);
|
|
1127
|
+
onEdit?.({ type: 'legend-toggle', series: label, hidden: false });
|
|
1123
1128
|
} else {
|
|
1124
1129
|
hiddenSeries.add(label);
|
|
1125
1130
|
entry.setAttribute('opacity', '0.3');
|
|
1126
1131
|
entry.setAttribute('aria-label', `${label}: hidden`);
|
|
1127
1132
|
onLegendToggle?.(label, false);
|
|
1133
|
+
onEdit?.({ type: 'legend-toggle', series: label, hidden: true });
|
|
1128
1134
|
}
|
|
1129
1135
|
|
|
1130
1136
|
// Toggle visibility of marks with matching series.
|
|
@@ -1448,7 +1454,12 @@ export function createChart(
|
|
|
1448
1454
|
);
|
|
1449
1455
|
|
|
1450
1456
|
// Wire legend interactivity
|
|
1451
|
-
cleanupLegend = wireLegendInteraction(
|
|
1457
|
+
cleanupLegend = wireLegendInteraction(
|
|
1458
|
+
svgElement,
|
|
1459
|
+
currentLayout,
|
|
1460
|
+
options?.onLegendToggle,
|
|
1461
|
+
options?.onEdit,
|
|
1462
|
+
);
|
|
1452
1463
|
|
|
1453
1464
|
// Wire chart event handlers (mark click/hover/leave, annotation click)
|
|
1454
1465
|
if (
|
|
@@ -1546,9 +1557,10 @@ export function createChart(
|
|
|
1546
1557
|
|
|
1547
1558
|
function doExport(format: 'svg'): string;
|
|
1548
1559
|
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1560
|
+
function doExport(format: 'jpg', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1549
1561
|
function doExport(format: 'csv'): string;
|
|
1550
1562
|
function doExport(
|
|
1551
|
-
format: 'svg' | 'png' | 'csv',
|
|
1563
|
+
format: 'svg' | 'png' | 'jpg' | 'csv',
|
|
1552
1564
|
exportOptions?: ExportOptions,
|
|
1553
1565
|
): string | Promise<Blob> {
|
|
1554
1566
|
if (!svgElement) {
|
|
@@ -1560,6 +1572,8 @@ export function createChart(
|
|
|
1560
1572
|
return exportSVG(svgElement);
|
|
1561
1573
|
case 'png':
|
|
1562
1574
|
return exportPNG(svgElement, exportOptions);
|
|
1575
|
+
case 'jpg':
|
|
1576
|
+
return exportJPG(svgElement, exportOptions);
|
|
1563
1577
|
case 'csv':
|
|
1564
1578
|
return exportCSV(
|
|
1565
1579
|
'data' in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : [],
|