@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 +102 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +299 -11
- 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/__tests__/svg-renderer.test.ts +47 -0
- package/src/export.ts +70 -0
- package/src/graph/canvas-renderer.ts +54 -0
- 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 +161 -1
- package/src/index.ts +2 -2
- package/src/mount.ts +21 -7
- package/src/svg-renderer.ts +95 -0
- package/src/table-renderer.ts +14 -0
|
@@ -1130,7 +1130,15 @@
|
|
|
1130
1130
|
}));
|
|
1131
1131
|
nodeMap = new Map(internalNodes.map((n) => [n.id, n]));
|
|
1132
1132
|
const { config } = msg;
|
|
1133
|
-
|
|
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.
|
|
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.
|
|
50
|
-
"@opendata-ai/openchart-engine": "2.
|
|
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
|
|
5
|
-
* validity
|
|
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
|
/**
|
package/src/graph/interaction.ts
CHANGED
|
@@ -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 +
|
|
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);
|
package/src/graph/simulation.ts
CHANGED
|
@@ -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 +
|
|
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);
|
package/src/graph/types.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/graph-mount.ts
CHANGED
|
@@ -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,
|