@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.
@@ -1130,7 +1130,15 @@
1130
1130
  }));
1131
1131
  nodeMap = new Map(internalNodes.map((n) => [n.id, n]));
1132
1132
  const { config } = msg;
1133
- simulation = simulation_default(internalNodes).force("link", link_default(msg.edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance)).force("charge", manyBody_default().strength(config.chargeStrength)).force("center", center_default(0, 0)).force("collide", collide_default().radius((d) => d.radius + 1)).force("gravityX", x_default2(0).strength(0.05)).force("gravityY", y_default2(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay);
1133
+ const linkForce = link_default(msg.edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
1134
+ if (config.linkStrength != null) {
1135
+ linkForce.strength(config.linkStrength);
1136
+ }
1137
+ const padding = config.collisionPadding ?? 2;
1138
+ simulation = simulation_default(internalNodes).force("link", linkForce).force("charge", manyBody_default().strength(config.chargeStrength)).force("collide", collide_default().radius((d) => d.radius + padding)).force("gravityX", x_default2(0).strength(0.05)).force("gravityY", y_default2(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay);
1139
+ if (config.centerForce !== false) {
1140
+ simulation.force("center", center_default(0, 0));
1141
+ }
1134
1142
  if (config.clustering) {
1135
1143
  const clusterFn = forceCluster(internalNodes, config.clustering.strength);
1136
1144
  simulation.force("cluster", clusterFn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -46,8 +46,8 @@
46
46
  "typecheck": "tsc --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@opendata-ai/openchart-core": "2.0.0",
50
- "@opendata-ai/openchart-engine": "2.0.0",
49
+ "@opendata-ai/openchart-core": "2.2.0",
50
+ "@opendata-ai/openchart-engine": "2.2.0",
51
51
  "d3-force": "^3.0.0",
52
52
  "d3-quadtree": "^3.0.1"
53
53
  },
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Export utility tests.
3
3
  *
4
- * Tests exportSVG and exportCSV functions directly, verifying SVG string
5
- * validity and CSV formatting with headers and proper escaping.
4
+ * Tests exportSVG, exportCSV, and exportJPG functions directly, verifying SVG string
5
+ * validity, CSV formatting with headers and proper escaping, and JPG export interface.
6
6
  */
7
7
 
8
8
  import type { CompileOptions } from '@opendata-ai/openchart-engine';
@@ -10,7 +10,7 @@ import { compileChart } from '@opendata-ai/openchart-engine';
10
10
  import { afterEach, describe, expect, it } from 'vitest';
11
11
  import { createContainer } from '../__test-fixtures__/dom';
12
12
  import { barSpec, lineSpec } from '../__test-fixtures__/specs';
13
- import { exportCSV, exportSVG } from '../export';
13
+ import { exportCSV, exportJPG, exportSVG } from '../export';
14
14
  import { renderChartSVG } from '../svg-renderer';
15
15
 
16
16
  // ---------------------------------------------------------------------------
@@ -148,3 +148,29 @@ describe('exportCSV', () => {
148
148
  expect(result.split('\n')[0]).toBe('x,y,z');
149
149
  });
150
150
  });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // exportJPG
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe('exportJPG', () => {
157
+ it('is a function that accepts an SVG element and options', () => {
158
+ expect(typeof exportJPG).toBe('function');
159
+ expect(exportJPG.length).toBeGreaterThanOrEqual(1);
160
+ });
161
+
162
+ it('returns a Promise when called with a rendered SVG element', () => {
163
+ const svg = renderToSVG();
164
+ const result = exportJPG(svg);
165
+ expect(result).toBeInstanceOf(Promise);
166
+ // Clean up: catch any rejection from happy-dom canvas limitations
167
+ result.catch(() => {});
168
+ });
169
+
170
+ it('accepts quality option between 0 and 1', () => {
171
+ const svg = renderToSVG();
172
+ const result = exportJPG(svg, { quality: 0.5, dpi: 1 });
173
+ expect(result).toBeInstanceOf(Promise);
174
+ result.catch(() => {});
175
+ });
176
+ });
@@ -607,3 +607,50 @@ describe('targeted mark snapshots', () => {
607
607
  expect(path!.getAttribute('d')).not.toBeNull();
608
608
  });
609
609
  });
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Brand watermark
613
+ // ---------------------------------------------------------------------------
614
+
615
+ describe('brand watermark', () => {
616
+ it('renders "Open" and "Data" text elements', () => {
617
+ const { svg } = renderSpec(lineSpec);
618
+ const openLink = svg.querySelector('.viz-axis-ref');
619
+ const dataLink = svg.querySelector('.viz-chrome-ref');
620
+ expect(openLink).not.toBeNull();
621
+ expect(dataLink).not.toBeNull();
622
+ expect(openLink!.querySelector('text')!.textContent).toBe('Open');
623
+ expect(dataLink!.querySelector('text')!.textContent).toBe('Data');
624
+ });
625
+
626
+ it('both elements link to tryopendata.ai', () => {
627
+ const { svg } = renderSpec(lineSpec);
628
+ const links = svg.querySelectorAll('a[href="https://tryopendata.ai"]');
629
+ expect(links.length).toBe(2);
630
+ });
631
+
632
+ it('elements are direct children of SVG root (no shared group)', () => {
633
+ const { svg } = renderSpec(lineSpec);
634
+ const openLink = svg.querySelector('.viz-axis-ref');
635
+ const dataLink = svg.querySelector('.viz-chrome-ref');
636
+ expect(openLink!.parentElement).toBe(svg);
637
+ expect(dataLink!.parentElement).toBe(svg);
638
+ });
639
+
640
+ it('elements are interleaved with other chart layers', () => {
641
+ const { svg } = renderSpec(lineSpec);
642
+ const children = Array.from(svg.children);
643
+ const openIdx = children.findIndex((el) => el.classList.contains('viz-axis-ref'));
644
+ const chromeIdx = children.findIndex((el) => el.classList.contains('viz-chrome'));
645
+ const dataIdx = children.findIndex((el) => el.classList.contains('viz-chrome-ref'));
646
+ // "Open" should come before chrome, "Data" after chrome
647
+ expect(openIdx).toBeLessThan(chromeIdx);
648
+ expect(dataIdx).toBeGreaterThan(chromeIdx);
649
+ });
650
+
651
+ it('skips watermark on very small charts', () => {
652
+ const { svg } = renderSpec(lineSpec, { width: 100, height: 80 });
653
+ const openLink = svg.querySelector('.viz-axis-ref');
654
+ expect(openLink).toBeNull();
655
+ });
656
+ });
package/src/export.ts CHANGED
@@ -74,6 +74,76 @@ export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptio
74
74
  });
75
75
  }
76
76
 
77
+ export interface JPGExportOptions extends PNGExportOptions {
78
+ /** JPEG quality from 0 to 1. Defaults to 0.92. */
79
+ quality?: number;
80
+ }
81
+
82
+ /**
83
+ * Render an SVG element to a JPEG Blob via a canvas.
84
+ *
85
+ * Same pipeline as exportPNG but outputs JPEG with configurable quality.
86
+ * The canvas is filled with white before drawing to avoid transparent
87
+ * backgrounds rendering as black in JPEG format.
88
+ *
89
+ * @param svgElement - The rendered SVG element.
90
+ * @param options - Optional DPI scaling and JPEG quality.
91
+ * @returns A Promise resolving to the JPEG Blob.
92
+ */
93
+ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob> {
94
+ const dpi = options?.dpi ?? 2;
95
+ const quality = options?.quality ?? 0.92;
96
+ const svgString = exportSVG(svgElement);
97
+
98
+ const width = parseFloat(svgElement.getAttribute('width') || '600');
99
+ const height = parseFloat(svgElement.getAttribute('height') || '400');
100
+
101
+ const canvas = document.createElement('canvas');
102
+ canvas.width = width * dpi;
103
+ canvas.height = height * dpi;
104
+
105
+ const ctx = canvas.getContext('2d');
106
+ if (!ctx) {
107
+ throw new Error('Canvas 2D context not available');
108
+ }
109
+
110
+ // Fill white background since JPEG doesn't support transparency
111
+ ctx.fillStyle = '#ffffff';
112
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
113
+
114
+ ctx.scale(dpi, dpi);
115
+
116
+ const img = new Image();
117
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
118
+ const url = URL.createObjectURL(blob);
119
+
120
+ return new Promise<Blob>((resolve, reject) => {
121
+ img.onload = () => {
122
+ ctx.drawImage(img, 0, 0);
123
+ URL.revokeObjectURL(url);
124
+
125
+ canvas.toBlob(
126
+ (result) => {
127
+ if (result) {
128
+ resolve(result);
129
+ } else {
130
+ reject(new Error('Canvas toBlob returned null'));
131
+ }
132
+ },
133
+ 'image/jpeg',
134
+ quality,
135
+ );
136
+ };
137
+
138
+ img.onerror = () => {
139
+ URL.revokeObjectURL(url);
140
+ reject(new Error('Failed to load SVG as image'));
141
+ };
142
+
143
+ img.src = url;
144
+ });
145
+ }
146
+
77
147
  /**
78
148
  * Convert an array of data objects to a CSV string.
79
149
  *
@@ -141,6 +141,7 @@ export class GraphCanvasRenderer {
141
141
  edges,
142
142
  transform,
143
143
  hoveredNodeId,
144
+ hoveredEdgeId,
144
145
  selectedNodeIds,
145
146
  adjacencyMap,
146
147
  theme,
@@ -193,6 +194,7 @@ export class GraphCanvasRenderer {
193
194
  hasActiveNode,
194
195
  connectedNodeIds,
195
196
  isGesturing ? null : searchMatches,
197
+ hoveredEdgeId,
196
198
  );
197
199
 
198
200
  // -- Draw nodes (batched by fill color) --
@@ -222,6 +224,35 @@ export class GraphCanvasRenderer {
222
224
  }
223
225
 
224
226
  ctx.restore();
227
+
228
+ // Brand watermark in screen coordinates (unaffected by pan/zoom)
229
+ this.drawBrand(ctx, cssWidth, cssHeight, theme);
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // Brand rendering
234
+ // -------------------------------------------------------------------------
235
+
236
+ private drawBrand(
237
+ ctx: CanvasRenderingContext2D,
238
+ w: number,
239
+ h: number,
240
+ theme: GraphRenderState['theme'],
241
+ ): void {
242
+ if (w < 120) return;
243
+ const { dpr } = this;
244
+ ctx.save();
245
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
246
+ const padding = theme.spacing.padding;
247
+ const x = w - padding;
248
+ const y = h - 4;
249
+ ctx.font = `600 20px ${theme.fonts.family}`;
250
+ ctx.fillStyle = theme.colors.axis;
251
+ ctx.globalAlpha = 0.5;
252
+ ctx.textAlign = 'right';
253
+ ctx.textBaseline = 'alphabetic';
254
+ ctx.fillText('OpenData', x, y);
255
+ ctx.restore();
225
256
  }
226
257
 
227
258
  // -------------------------------------------------------------------------
@@ -234,13 +265,21 @@ export class GraphCanvasRenderer {
234
265
  hasActiveNode: boolean,
235
266
  connectedNodeIds: Set<string>,
236
267
  searchMatches: Set<string> | null,
268
+ hoveredEdgeId: string | null,
237
269
  ): void {
238
270
  // Classify edges by alpha level, then batch by visual style within each level
239
271
  const dimmedEdges: PositionedEdge[] = [];
240
272
  const defaultEdges: PositionedEdge[] = [];
241
273
  const connectedEdges: PositionedEdge[] = [];
274
+ let hoveredEdge: PositionedEdge | null = null;
242
275
 
243
276
  for (const edge of edges) {
277
+ const edgeId = `${edge.source}->${edge.target}`;
278
+ if (edgeId === hoveredEdgeId) {
279
+ hoveredEdge = edge;
280
+ continue; // Draw hovered edge last, on top
281
+ }
282
+
244
283
  const isConnected =
245
284
  hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
246
285
  const isDimmed = hasActiveNode && !isConnected;
@@ -258,6 +297,21 @@ export class GraphCanvasRenderer {
258
297
  this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
259
298
  this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
260
299
  this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
300
+
301
+ // Draw hovered edge on top with highlight
302
+ if (hoveredEdge) {
303
+ const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
304
+ ctx.setLineDash(dash);
305
+ ctx.strokeStyle = hoveredEdge.stroke;
306
+ ctx.lineWidth = hoveredEdge.strokeWidth * 2;
307
+ ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
308
+ ctx.beginPath();
309
+ ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
310
+ ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
311
+ ctx.stroke();
312
+ ctx.setLineDash([]);
313
+ ctx.globalAlpha = 1;
314
+ }
261
315
  }
262
316
 
263
317
  /**
@@ -26,6 +26,8 @@ const HIT_DISTANCE = 5;
26
26
  export interface InteractionCallbacks {
27
27
  onTransformChange(transform: ZoomTransform): void;
28
28
  onHoverChange(nodeId: string | null): void;
29
+ /** Called during mouse move when no node is hit, with graph-space coordinates for edge hit testing. */
30
+ onBackgroundHover?(graphX: number, graphY: number, screenX: number, screenY: number): void;
29
31
  onSelectionChange(nodeIds: string[]): void;
30
32
  onNodeDragStart(nodeId: string): void;
31
33
  onNodeDrag(nodeId: string, x: number, y: number): void;
@@ -198,6 +200,12 @@ export class GraphInteractionManager {
198
200
  const hitId = this.hitTest(x, y);
199
201
  this.callbacks.onHoverChange(hitId);
200
202
 
203
+ // If no node hit, check edges via callback
204
+ if (!hitId) {
205
+ const graph = this.transform.screenToGraph(x, y);
206
+ this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
207
+ }
208
+
201
209
  // Update cursor
202
210
  this.canvas.style.cursor = hitId ? 'pointer' : 'default';
203
211
  }
@@ -60,6 +60,9 @@ interface SimConfig {
60
60
  alphaDecay: number;
61
61
  velocityDecay: number;
62
62
  collisionRadius: number;
63
+ collisionPadding?: number;
64
+ linkStrength?: number;
65
+ centerForce?: boolean;
63
66
  }
64
67
 
65
68
  type InMessage =
@@ -173,18 +176,21 @@ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
173
176
 
174
177
  const { config } = msg;
175
178
 
179
+ const linkForce = forceLink(msg.edges.map((e) => ({ ...e })))
180
+ .id((d) => (d as InternalNode).id)
181
+ .distance(config.linkDistance);
182
+ if (config.linkStrength != null) {
183
+ linkForce.strength(config.linkStrength);
184
+ }
185
+
186
+ const padding = config.collisionPadding ?? 2;
187
+
176
188
  simulation = forceSimulation<InternalNode>(internalNodes)
177
- .force(
178
- 'link',
179
- forceLink(msg.edges.map((e) => ({ ...e })))
180
- .id((d) => (d as InternalNode).id)
181
- .distance(config.linkDistance),
182
- )
189
+ .force('link', linkForce)
183
190
  .force('charge', forceManyBody().strength(config.chargeStrength))
184
- .force('center', forceCenter(0, 0))
185
191
  .force(
186
192
  'collide',
187
- forceCollide<InternalNode>().radius((d) => d.radius + 1),
193
+ forceCollide<InternalNode>().radius((d) => d.radius + padding),
188
194
  )
189
195
  // Weak gravity keeps disconnected nodes from drifting far from center
190
196
  .force('gravityX', forceX<InternalNode>(0).strength(0.05))
@@ -192,6 +198,11 @@ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
192
198
  .alphaDecay(config.alphaDecay)
193
199
  .velocityDecay(config.velocityDecay);
194
200
 
201
+ // Center force (default true)
202
+ if (config.centerForce !== false) {
203
+ simulation.force('center', forceCenter(0, 0));
204
+ }
205
+
195
206
  // Add clustering force if configured
196
207
  if (config.clustering) {
197
208
  const clusterFn = forceCluster(internalNodes, config.clustering.strength);
@@ -278,18 +278,21 @@ export class SimulationManager {
278
278
 
279
279
  this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
280
280
 
281
+ const linkForce = forceLink(edges.map((e) => ({ ...e })))
282
+ .id((d) => (d as SyncNode).id)
283
+ .distance(config.linkDistance);
284
+ if (config.linkStrength != null) {
285
+ linkForce.strength(config.linkStrength);
286
+ }
287
+
288
+ const padding = config.collisionPadding ?? 2;
289
+
281
290
  this.syncSim = forceSimulation<SyncNode>(this.syncNodes)
282
- .force(
283
- 'link',
284
- forceLink(edges.map((e) => ({ ...e })))
285
- .id((d) => (d as SyncNode).id)
286
- .distance(config.linkDistance),
287
- )
291
+ .force('link', linkForce)
288
292
  .force('charge', forceManyBody().strength(config.chargeStrength))
289
- .force('center', forceCenter(0, 0))
290
293
  .force(
291
294
  'collide',
292
- forceCollide<SyncNode>().radius((d) => d.radius + 1),
295
+ forceCollide<SyncNode>().radius((d) => d.radius + padding),
293
296
  )
294
297
  // Weak gravity keeps disconnected nodes from drifting far from center
295
298
  .force('gravityX', forceX<SyncNode>(0).strength(0.05))
@@ -298,6 +301,11 @@ export class SimulationManager {
298
301
  .velocityDecay(config.velocityDecay)
299
302
  .stop(); // Don't auto-run; we tick manually
300
303
 
304
+ // Center force (default true)
305
+ if (config.centerForce !== false) {
306
+ this.syncSim.force('center', forceCenter(0, 0));
307
+ }
308
+
301
309
  // Add clustering force if configured
302
310
  if (config.clustering) {
303
311
  const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
@@ -35,6 +35,7 @@ export interface GraphRenderState {
35
35
  edges: PositionedEdge[];
36
36
  transform: { x: number; y: number; k: number };
37
37
  hoveredNodeId: string | null;
38
+ hoveredEdgeId: string | null;
38
39
  selectedNodeIds: Set<string>;
39
40
  adjacencyMap: Map<string, Set<string>>;
40
41
  theme: ResolvedTheme;
@@ -34,6 +34,9 @@ export interface WorkerSimulationConfig {
34
34
  alphaDecay: number;
35
35
  velocityDecay: number;
36
36
  collisionRadius: number;
37
+ collisionPadding?: number;
38
+ linkStrength?: number;
39
+ centerForce?: boolean;
37
40
  }
38
41
 
39
42
  // ---------------------------------------------------------------------------
@@ -37,11 +37,15 @@ export interface GraphMountOptions {
37
37
  responsive?: boolean;
38
38
  onNodeClick?: (node: Record<string, unknown>) => void;
39
39
  onNodeDoubleClick?: (node: Record<string, unknown>) => void;
40
+ onNodeHover?: (node: Record<string, unknown> | null) => void;
41
+ onEdgeHover?: (edge: Record<string, unknown> | null) => void;
40
42
  onSelectionChange?: (nodeIds: string[]) => void;
41
43
  }
42
44
 
43
45
  export interface GraphInstance {
44
46
  update(spec: GraphSpec): void;
47
+ /** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */
48
+ updateVisuals(spec: GraphSpec): void;
45
49
  search(query: string): void;
46
50
  clearSearch(): void;
47
51
  zoomToFit(): void;
@@ -107,6 +111,7 @@ export function createGraph(
107
111
  let positionedEdges: PositionedEdge[] = [];
108
112
  let adjacencyMap = new Map<string, Set<string>>();
109
113
  let hoveredNodeId: string | null = null;
114
+ let hoveredEdgeId: string | null = null;
110
115
  let selectedNodeIds = new Set<string>();
111
116
  let animFrameId: number | null = null;
112
117
  let needsRender = false;
@@ -185,6 +190,61 @@ export function createGraph(
185
190
  return node?.data ?? {};
186
191
  }
187
192
 
193
+ /**
194
+ * Point-to-line-segment distance for edge hit testing.
195
+ * Returns the shortest distance from point (px, py) to the segment (ax, ay)-(bx, by).
196
+ */
197
+ function pointToSegmentDist(
198
+ px: number,
199
+ py: number,
200
+ ax: number,
201
+ ay: number,
202
+ bx: number,
203
+ by: number,
204
+ ): number {
205
+ const dx = bx - ax;
206
+ const dy = by - ay;
207
+ const lenSq = dx * dx + dy * dy;
208
+ if (lenSq === 0) return Math.hypot(px - ax, py - ay);
209
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
210
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
211
+ }
212
+
213
+ /**
214
+ * Find the edge closest to a graph-space point, within a threshold.
215
+ * Returns an edge key "source->target" or null.
216
+ */
217
+ function hitTestEdge(graphX: number, graphY: number, threshold: number): string | null {
218
+ let bestDist = threshold;
219
+ let bestEdgeId: string | null = null;
220
+
221
+ for (const edge of positionedEdges) {
222
+ const dist = pointToSegmentDist(
223
+ graphX,
224
+ graphY,
225
+ edge.sourceX,
226
+ edge.sourceY,
227
+ edge.targetX,
228
+ edge.targetY,
229
+ );
230
+ if (dist < bestDist) {
231
+ bestDist = dist;
232
+ bestEdgeId = `${edge.source}->${edge.target}`;
233
+ }
234
+ }
235
+
236
+ return bestEdgeId;
237
+ }
238
+
239
+ /**
240
+ * Look up edge data by edge id ("source->target").
241
+ */
242
+ function edgeDataById(edgeId: string): Record<string, unknown> | null {
243
+ const [source, target] = edgeId.split('->');
244
+ const edge = compilation.edges.find((e) => e.source === source && e.target === target);
245
+ return edge?.data ?? null;
246
+ }
247
+
188
248
  // ---------------------------------------------------------------------------
189
249
  // DOM creation
190
250
  // ---------------------------------------------------------------------------
@@ -289,6 +349,9 @@ export function createGraph(
289
349
  alphaDecay: config.alphaDecay,
290
350
  velocityDecay: config.velocityDecay,
291
351
  collisionRadius: config.collisionRadius,
352
+ collisionPadding: config.collisionPadding,
353
+ linkStrength: config.linkStrength,
354
+ centerForce: config.centerForce,
292
355
  });
293
356
 
294
357
  simulation.onTick((positions, _alpha) => {
@@ -365,6 +428,7 @@ export function createGraph(
365
428
  edges: positionedEdges,
366
429
  transform: { x: transform.x, y: transform.y, k: transform.k },
367
430
  hoveredNodeId,
431
+ hoveredEdgeId,
368
432
  selectedNodeIds,
369
433
  adjacencyMap,
370
434
  theme: compilation.theme,
@@ -396,8 +460,20 @@ export function createGraph(
396
460
  needsRender = true;
397
461
  scheduleRender();
398
462
 
463
+ // Fire onNodeHover callback
464
+ if (nodeId) {
465
+ options?.onNodeHover?.(nodeDataById(nodeId));
466
+ } else {
467
+ options?.onNodeHover?.(null);
468
+ }
469
+
399
470
  // Show or hide tooltip
400
471
  if (nodeId && tooltipManager) {
472
+ // Clear edge hover when hovering a node
473
+ if (hoveredEdgeId) {
474
+ hoveredEdgeId = null;
475
+ options?.onEdgeHover?.(null);
476
+ }
401
477
  const content = compilation.tooltipDescriptors.get(nodeId);
402
478
  if (content) {
403
479
  const node = positionedNodes.find((n) => n.id === nodeId);
@@ -406,10 +482,46 @@ export function createGraph(
406
482
  tooltipManager.show(content, screen.x, screen.y);
407
483
  }
408
484
  }
409
- } else {
485
+ } else if (!nodeId) {
486
+ // Tooltip hiding handled in onBackgroundHover (edge may show tooltip)
487
+ // If no edge hover happens, tooltip stays hidden
410
488
  tooltipManager?.hide();
411
489
  }
412
490
  },
491
+ onBackgroundHover(graphX, graphY, screenX, screenY) {
492
+ // Edge hit testing: check proximity to edge line segments
493
+ const transform = interactionManager?.getTransform();
494
+ const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
495
+ const edgeId = hitTestEdge(graphX, graphY, threshold);
496
+
497
+ if (edgeId !== hoveredEdgeId) {
498
+ hoveredEdgeId = edgeId;
499
+ needsRender = true;
500
+ scheduleRender();
501
+
502
+ if (edgeId) {
503
+ const data = edgeDataById(edgeId);
504
+ options?.onEdgeHover?.(data);
505
+
506
+ // Show edge tooltip
507
+ if (tooltipManager && data) {
508
+ const fields = Object.entries(data)
509
+ .filter(([key]) => key !== 'source' && key !== 'target')
510
+ .filter(([, value]) => value != null)
511
+ .map(([key, value]) => ({
512
+ label: key,
513
+ value: typeof value === 'number' ? value.toLocaleString() : String(value),
514
+ }));
515
+
516
+ const [source, target] = edgeId.split('->');
517
+ tooltipManager.show({ title: `${source} → ${target}`, fields }, screenX, screenY);
518
+ }
519
+ } else {
520
+ options?.onEdgeHover?.(null);
521
+ tooltipManager?.hide();
522
+ }
523
+ }
524
+ },
413
525
  onSelectionChange(nodeIds) {
414
526
  selectedNodeIds = new Set(nodeIds);
415
527
  needsRender = true;
@@ -567,10 +679,56 @@ export function createGraph(
567
679
 
568
680
  // Reset state
569
681
  hoveredNodeId = null;
682
+ hoveredEdgeId = null;
570
683
  selectedNodeIds = new Set();
571
684
  searchManager.clearSearch();
572
685
  }
573
686
 
687
+ function updateVisuals(newSpec: GraphSpec): void {
688
+ if (destroyed) return;
689
+ currentSpec = newSpec;
690
+
691
+ // Build a position lookup from current positioned nodes
692
+ const posMap = new Map<string, { x: number; y: number }>();
693
+ for (const node of positionedNodes) {
694
+ posMap.set(node.id, { x: node.x, y: node.y });
695
+ }
696
+
697
+ // Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
698
+ compilation = compile();
699
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
700
+
701
+ // Transfer positions to new compiled nodes
702
+ positionedNodes = compilation.nodes.map((node) => {
703
+ const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
704
+ return { ...node, x: pos.x, y: pos.y };
705
+ });
706
+
707
+ // Rebuild positioned edges from existing positions
708
+ positionedEdges = compilation.edges.map((edge) => {
709
+ const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
710
+ const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
711
+ return {
712
+ ...edge,
713
+ sourceX: src.x,
714
+ sourceY: src.y,
715
+ targetX: tgt.x,
716
+ targetY: tgt.y,
717
+ };
718
+ });
719
+
720
+ // Rebuild spatial index with updated visuals
721
+ spatialIndex.rebuild(positionedNodes);
722
+
723
+ // Update DOM chrome/legend
724
+ renderChrome();
725
+ renderLegend();
726
+
727
+ // Re-render canvas without restarting simulation
728
+ needsRender = true;
729
+ scheduleRender();
730
+ }
731
+
574
732
  function teardownSubsystems(): void {
575
733
  if (animFrameId !== null) {
576
734
  cancelAnimationFrame(animFrameId);
@@ -631,6 +789,7 @@ export function createGraph(
631
789
  // Return a no-op instance so callers don't crash
632
790
  return {
633
791
  update() {},
792
+ updateVisuals() {},
634
793
  search() {},
635
794
  clearSearch() {},
636
795
  zoomToFit() {},
@@ -651,6 +810,7 @@ export function createGraph(
651
810
 
652
811
  return {
653
812
  update,
813
+ updateVisuals,
654
814
  search,
655
815
  clearSearch,
656
816
  zoomToFit,