@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 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 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,
@@ -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
- this.syncSim = forceSimulation(this.syncNodes).force(
1200
- "link",
1201
- forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance)
1202
- ).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(
1203
1292
  "collide",
1204
- forceCollide().radius((d) => d.radius + 1)
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(svgElement, currentLayout, options?.onLegendToggle);
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,