@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.
@@ -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
- legendEl = document.createElement('div');
222
- legendEl.className = 'viz-graph-legend';
223
- renderLegend();
224
- wrapper.appendChild(legendEl);
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
- tooltipManager = createTooltipManager(wrapper!);
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 PNGExportOptions } from './export';
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 PNGExportOptions {
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
- * Optionally calls onLegendToggle when a series is toggled.
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(svgElement, currentLayout, options?.onLegendToggle);
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 : [],