@opendata-ai/openchart-vanilla 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +28 -3
- package/dist/index.js +217 -24
- 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/export.ts +70 -0
- package/src/graph/canvas-renderer.ts +38 -10
- 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 +174 -6
- package/src/index.ts +2 -2
- package/src/mount.ts +21 -7
|
@@ -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.1
|
|
3
|
+
"version": "2.2.1",
|
|
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.1
|
|
50
|
-
"@opendata-ai/openchart-engine": "2.1
|
|
49
|
+
"@opendata-ai/openchart-core": "2.2.1",
|
|
50
|
+
"@opendata-ai/openchart-engine": "2.2.1",
|
|
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
|
+
});
|
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
|
*
|
|
@@ -18,15 +18,15 @@ import type { GraphRenderState, PositionedEdge, PositionedNode } from './types';
|
|
|
18
18
|
// Constants
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
|
-
const LABEL_FONT_MIN =
|
|
22
|
-
const LABEL_FONT_MAX =
|
|
21
|
+
const LABEL_FONT_MIN = 8;
|
|
22
|
+
const LABEL_FONT_MAX = 12;
|
|
23
23
|
const EDGE_ALPHA_DEFAULT = 0.35;
|
|
24
24
|
const EDGE_ALPHA_CONNECTED = 1.0;
|
|
25
25
|
const EDGE_ALPHA_DIMMED = 0.05;
|
|
26
26
|
const SEARCH_NON_MATCH_ALPHA = 0.15;
|
|
27
27
|
const GLOW_NODE_THRESHOLD = 2000;
|
|
28
|
-
const GLOW_RADIUS_MULTIPLIER = 1.
|
|
29
|
-
const GLOW_ALPHA = 0.
|
|
28
|
+
const GLOW_RADIUS_MULTIPLIER = 1.3;
|
|
29
|
+
const GLOW_ALPHA = 0.15;
|
|
30
30
|
const CULL_MARGIN = 50;
|
|
31
31
|
const TWO_PI = Math.PI * 2;
|
|
32
32
|
|
|
@@ -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,
|
|
@@ -179,9 +180,11 @@ export class GraphCanvasRenderer {
|
|
|
179
180
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
180
181
|
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
181
182
|
|
|
182
|
-
// Fill background
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// Fill background (skip if transparent to let page background show through)
|
|
184
|
+
if (theme.colors.background !== 'transparent') {
|
|
185
|
+
ctx.fillStyle = theme.colors.background;
|
|
186
|
+
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
187
|
+
}
|
|
185
188
|
|
|
186
189
|
ctx.translate(transform.x, transform.y);
|
|
187
190
|
ctx.scale(transform.k, transform.k);
|
|
@@ -193,6 +196,7 @@ export class GraphCanvasRenderer {
|
|
|
193
196
|
hasActiveNode,
|
|
194
197
|
connectedNodeIds,
|
|
195
198
|
isGesturing ? null : searchMatches,
|
|
199
|
+
hoveredEdgeId,
|
|
196
200
|
);
|
|
197
201
|
|
|
198
202
|
// -- Draw nodes (batched by fill color) --
|
|
@@ -263,13 +267,21 @@ export class GraphCanvasRenderer {
|
|
|
263
267
|
hasActiveNode: boolean,
|
|
264
268
|
connectedNodeIds: Set<string>,
|
|
265
269
|
searchMatches: Set<string> | null,
|
|
270
|
+
hoveredEdgeId: string | null,
|
|
266
271
|
): void {
|
|
267
272
|
// Classify edges by alpha level, then batch by visual style within each level
|
|
268
273
|
const dimmedEdges: PositionedEdge[] = [];
|
|
269
274
|
const defaultEdges: PositionedEdge[] = [];
|
|
270
275
|
const connectedEdges: PositionedEdge[] = [];
|
|
276
|
+
let hoveredEdge: PositionedEdge | null = null;
|
|
271
277
|
|
|
272
278
|
for (const edge of edges) {
|
|
279
|
+
const edgeId = `${edge.source}->${edge.target}`;
|
|
280
|
+
if (edgeId === hoveredEdgeId) {
|
|
281
|
+
hoveredEdge = edge;
|
|
282
|
+
continue; // Draw hovered edge last, on top
|
|
283
|
+
}
|
|
284
|
+
|
|
273
285
|
const isConnected =
|
|
274
286
|
hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
|
|
275
287
|
const isDimmed = hasActiveNode && !isConnected;
|
|
@@ -287,6 +299,21 @@ export class GraphCanvasRenderer {
|
|
|
287
299
|
this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
|
|
288
300
|
this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
|
|
289
301
|
this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
|
|
302
|
+
|
|
303
|
+
// Draw hovered edge on top with highlight
|
|
304
|
+
if (hoveredEdge) {
|
|
305
|
+
const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
|
|
306
|
+
ctx.setLineDash(dash);
|
|
307
|
+
ctx.strokeStyle = hoveredEdge.stroke;
|
|
308
|
+
ctx.lineWidth = hoveredEdge.strokeWidth * 2;
|
|
309
|
+
ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
|
|
310
|
+
ctx.beginPath();
|
|
311
|
+
ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
|
|
312
|
+
ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
|
|
313
|
+
ctx.stroke();
|
|
314
|
+
ctx.setLineDash([]);
|
|
315
|
+
ctx.globalAlpha = 1;
|
|
316
|
+
}
|
|
290
317
|
}
|
|
291
318
|
|
|
292
319
|
/**
|
|
@@ -613,7 +640,7 @@ export class GraphCanvasRenderer {
|
|
|
613
640
|
theme: GraphRenderState['theme'],
|
|
614
641
|
): void {
|
|
615
642
|
// Font size inversely scaled by zoom, clamped to readable range
|
|
616
|
-
const rawSize =
|
|
643
|
+
const rawSize = 10 / zoom;
|
|
617
644
|
const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
|
|
618
645
|
|
|
619
646
|
ctx.font = `${fontSize}px ${theme.fonts.family}`;
|
|
@@ -635,8 +662,9 @@ export class GraphCanvasRenderer {
|
|
|
635
662
|
|
|
636
663
|
const labelY = node.y + node.radius + 3;
|
|
637
664
|
|
|
638
|
-
// Dark halo for readability
|
|
639
|
-
ctx.strokeStyle =
|
|
665
|
+
// Dark halo for readability (fall back to semi-transparent black when bg is transparent)
|
|
666
|
+
ctx.strokeStyle =
|
|
667
|
+
theme.colors.background === 'transparent' ? 'rgba(0, 0, 0, 0.7)' : theme.colors.background;
|
|
640
668
|
ctx.lineWidth = 3;
|
|
641
669
|
ctx.lineJoin = 'round';
|
|
642
670
|
ctx.strokeText(node.label, node.x, labelY);
|
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
|
// ---------------------------------------------------------------------------
|