@opendata-ai/openchart-vanilla 2.1.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/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 PNGExportOptions {
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,
@@ -218,11 +262,17 @@ var GraphCanvasRenderer = class {
218
262
  // -------------------------------------------------------------------------
219
263
  // Batched edge drawing
220
264
  // -------------------------------------------------------------------------
221
- drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches) {
265
+ drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches, hoveredEdgeId) {
222
266
  const dimmedEdges = [];
223
267
  const defaultEdges = [];
224
268
  const connectedEdges = [];
269
+ let hoveredEdge = null;
225
270
  for (const edge of edges) {
271
+ const edgeId = `${edge.source}->${edge.target}`;
272
+ if (edgeId === hoveredEdgeId) {
273
+ hoveredEdge = edge;
274
+ continue;
275
+ }
226
276
  const isConnected = hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
227
277
  const isDimmed = hasActiveNode && !isConnected;
228
278
  if (isConnected) {
@@ -236,6 +286,19 @@ var GraphCanvasRenderer = class {
236
286
  this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
237
287
  this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
238
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
+ }
239
302
  }
240
303
  /**
241
304
  * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
@@ -718,6 +781,10 @@ var GraphInteractionManager = class {
718
781
  }
719
782
  const hitId = this.hitTest(x, y);
720
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
+ }
721
788
  this.canvas.style.cursor = hitId ? "pointer" : "default";
722
789
  }
723
790
  onMouseUp(e) {
@@ -1216,13 +1283,18 @@ var SimulationManager = class _SimulationManager {
1216
1283
  community: n.community
1217
1284
  }));
1218
1285
  this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
1219
- this.syncSim = forceSimulation(this.syncNodes).force(
1220
- "link",
1221
- forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance)
1222
- ).force("charge", forceManyBody().strength(config.chargeStrength)).force("center", forceCenter(0, 0)).force(
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(
1223
1292
  "collide",
1224
- forceCollide().radius((d) => d.radius + 1)
1293
+ forceCollide().radius((d) => d.radius + padding)
1225
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
+ }
1226
1298
  if (config.clustering) {
1227
1299
  const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
1228
1300
  this.syncSim.force("cluster", clusterFn);
@@ -1467,6 +1539,7 @@ function createGraph(container, spec, options) {
1467
1539
  let positionedEdges = [];
1468
1540
  let adjacencyMap = /* @__PURE__ */ new Map();
1469
1541
  let hoveredNodeId = null;
1542
+ let hoveredEdgeId = null;
1470
1543
  let selectedNodeIds = /* @__PURE__ */ new Set();
1471
1544
  let animFrameId = null;
1472
1545
  let needsRender = false;
@@ -1527,6 +1600,38 @@ function createGraph(container, spec, options) {
1527
1600
  const node = compilation.nodes.find((n) => n.id === nodeId);
1528
1601
  return node?.data ?? {};
1529
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
+ }
1530
1635
  function createDOM() {
1531
1636
  const { width, height } = getContainerDimensions();
1532
1637
  const isDark = resolveDarkMode(options?.darkMode);
@@ -1601,7 +1706,10 @@ function createGraph(container, spec, options) {
1601
1706
  clustering: config.clustering,
1602
1707
  alphaDecay: config.alphaDecay,
1603
1708
  velocityDecay: config.velocityDecay,
1604
- collisionRadius: config.collisionRadius
1709
+ collisionRadius: config.collisionRadius,
1710
+ collisionPadding: config.collisionPadding,
1711
+ linkStrength: config.linkStrength,
1712
+ centerForce: config.centerForce
1605
1713
  });
1606
1714
  simulation.onTick((positions, _alpha) => {
1607
1715
  if (destroyed) return;
@@ -1661,6 +1769,7 @@ function createGraph(container, spec, options) {
1661
1769
  edges: positionedEdges,
1662
1770
  transform: { x: transform.x, y: transform.y, k: transform.k },
1663
1771
  hoveredNodeId,
1772
+ hoveredEdgeId,
1664
1773
  selectedNodeIds,
1665
1774
  adjacencyMap,
1666
1775
  theme: compilation.theme,
@@ -1683,7 +1792,16 @@ function createGraph(container, spec, options) {
1683
1792
  hoveredNodeId = nodeId;
1684
1793
  needsRender = true;
1685
1794
  scheduleRender();
1795
+ if (nodeId) {
1796
+ options?.onNodeHover?.(nodeDataById(nodeId));
1797
+ } else {
1798
+ options?.onNodeHover?.(null);
1799
+ }
1686
1800
  if (nodeId && tooltipManager) {
1801
+ if (hoveredEdgeId) {
1802
+ hoveredEdgeId = null;
1803
+ options?.onEdgeHover?.(null);
1804
+ }
1687
1805
  const content = compilation.tooltipDescriptors.get(nodeId);
1688
1806
  if (content) {
1689
1807
  const node = positionedNodes.find((n) => n.id === nodeId);
@@ -1692,10 +1810,35 @@ function createGraph(container, spec, options) {
1692
1810
  tooltipManager.show(content, screen.x, screen.y);
1693
1811
  }
1694
1812
  }
1695
- } else {
1813
+ } else if (!nodeId) {
1696
1814
  tooltipManager?.hide();
1697
1815
  }
1698
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
+ },
1699
1842
  onSelectionChange(nodeIds) {
1700
1843
  selectedNodeIds = new Set(nodeIds);
1701
1844
  needsRender = true;
@@ -1821,9 +1964,40 @@ function createGraph(container, spec, options) {
1821
1964
  initSimulation();
1822
1965
  initInteraction();
1823
1966
  hoveredNodeId = null;
1967
+ hoveredEdgeId = null;
1824
1968
  selectedNodeIds = /* @__PURE__ */ new Set();
1825
1969
  searchManager.clearSearch();
1826
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
+ }
1827
2001
  function teardownSubsystems() {
1828
2002
  if (animFrameId !== null) {
1829
2003
  cancelAnimationFrame(animFrameId);
@@ -1873,6 +2047,8 @@ function createGraph(container, spec, options) {
1873
2047
  return {
1874
2048
  update() {
1875
2049
  },
2050
+ updateVisuals() {
2051
+ },
1876
2052
  search() {
1877
2053
  },
1878
2054
  clearSearch() {
@@ -1897,6 +2073,7 @@ function createGraph(container, spec, options) {
1897
2073
  }
1898
2074
  return {
1899
2075
  update,
2076
+ updateVisuals,
1900
2077
  search,
1901
2078
  clearSearch,
1902
2079
  zoomToFit,
@@ -3343,7 +3520,7 @@ function wireSeriesLabelDrag(svg, spec, onEdit, setDragging) {
3343
3520
  }
3344
3521
  };
3345
3522
  }
3346
- function wireLegendInteraction(svg, _layout, onLegendToggle) {
3523
+ function wireLegendInteraction(svg, _layout, onLegendToggle, onEdit) {
3347
3524
  const legendEntries = svg.querySelectorAll("[data-legend-index]");
3348
3525
  const cleanups = [];
3349
3526
  const hiddenSeries = /* @__PURE__ */ new Set();
@@ -3356,11 +3533,13 @@ function wireLegendInteraction(svg, _layout, onLegendToggle) {
3356
3533
  entry.setAttribute("opacity", "1");
3357
3534
  entry.setAttribute("aria-label", `${label}: visible`);
3358
3535
  onLegendToggle?.(label, true);
3536
+ onEdit?.({ type: "legend-toggle", series: label, hidden: false });
3359
3537
  } else {
3360
3538
  hiddenSeries.add(label);
3361
3539
  entry.setAttribute("opacity", "0.3");
3362
3540
  entry.setAttribute("aria-label", `${label}: hidden`);
3363
3541
  onLegendToggle?.(label, false);
3542
+ onEdit?.({ type: "legend-toggle", series: label, hidden: true });
3364
3543
  }
3365
3544
  const marks = svg.querySelectorAll(".viz-mark");
3366
3545
  for (const mark of marks) {
@@ -3593,7 +3772,12 @@ function createChart(container, spec, options) {
3593
3772
  tooltipManager,
3594
3773
  currentLayout
3595
3774
  );
3596
- cleanupLegend = wireLegendInteraction(svgElement, currentLayout, options?.onLegendToggle);
3775
+ cleanupLegend = wireLegendInteraction(
3776
+ svgElement,
3777
+ currentLayout,
3778
+ options?.onLegendToggle,
3779
+ options?.onEdit
3780
+ );
3597
3781
  if (options?.onMarkClick || options?.onMarkHover || options?.onMarkLeave || options?.onAnnotationClick) {
3598
3782
  const specAnnotations = "annotations" in currentSpec && Array.isArray(currentSpec.annotations) ? currentSpec.annotations : [];
3599
3783
  cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
@@ -3659,6 +3843,8 @@ function createChart(container, spec, options) {
3659
3843
  return exportSVG(svgElement);
3660
3844
  case "png":
3661
3845
  return exportPNG(svgElement, exportOptions);
3846
+ case "jpg":
3847
+ return exportJPG(svgElement, exportOptions);
3662
3848
  case "csv":
3663
3849
  return exportCSV(
3664
3850
  "data" in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : []
@@ -4828,6 +5014,7 @@ export {
4828
5014
  createTable,
4829
5015
  createTooltipManager,
4830
5016
  exportCSV,
5017
+ exportJPG,
4831
5018
  exportPNG,
4832
5019
  exportSVG,
4833
5020
  observeResize,