@opendata-ai/openchart-vanilla 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial index for fast node hit-testing.
|
|
3
|
+
*
|
|
4
|
+
* Wraps d3-quadtree to provide findNearest (accounts for node radius)
|
|
5
|
+
* and findInRect queries. Tracks a generation counter to avoid unnecessary
|
|
6
|
+
* rebuilds when positions haven't changed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Quadtree, quadtree } from 'd3-quadtree';
|
|
10
|
+
import type { PositionedNode } from './types';
|
|
11
|
+
|
|
12
|
+
export class SpatialIndex {
|
|
13
|
+
private tree: Quadtree<PositionedNode> | null = null;
|
|
14
|
+
private nodes: PositionedNode[] = [];
|
|
15
|
+
private maxRadius = 0;
|
|
16
|
+
private generation = 0;
|
|
17
|
+
|
|
18
|
+
/** Rebuild the quadtree from the current set of positioned nodes. */
|
|
19
|
+
rebuild(nodes: PositionedNode[]): void {
|
|
20
|
+
this.nodes = nodes;
|
|
21
|
+
this.maxRadius = 0;
|
|
22
|
+
for (const n of nodes) {
|
|
23
|
+
if (n.radius > this.maxRadius) this.maxRadius = n.radius;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.tree = quadtree<PositionedNode>()
|
|
27
|
+
.x((d) => d.x)
|
|
28
|
+
.y((d) => d.y)
|
|
29
|
+
.addAll(nodes);
|
|
30
|
+
this.generation++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Current generation counter. Increments on each rebuild. */
|
|
34
|
+
getGeneration(): number {
|
|
35
|
+
return this.generation;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find the nearest node to (x, y) within maxDistance.
|
|
40
|
+
* Accounts for node radius: a hit occurs if the point is inside
|
|
41
|
+
* the node circle (distance to center < node.radius), or if the
|
|
42
|
+
* edge-to-edge distance is within maxDistance.
|
|
43
|
+
*/
|
|
44
|
+
findNearest(x: number, y: number, maxDistance: number = Infinity): PositionedNode | null {
|
|
45
|
+
if (!this.tree || this.nodes.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
// The effective search radius for the quadtree needs to include
|
|
48
|
+
// the largest node radius, because we might be "inside" a large node
|
|
49
|
+
// whose center is far from our search point.
|
|
50
|
+
const searchRadius = maxDistance + this.maxRadius;
|
|
51
|
+
|
|
52
|
+
let best: PositionedNode | null = null;
|
|
53
|
+
let bestEffectiveDist = maxDistance + this.maxRadius + 1;
|
|
54
|
+
|
|
55
|
+
this.tree.visit((node, x0, y0, x1, y1) => {
|
|
56
|
+
// Closest point in this quad to our target
|
|
57
|
+
const closestX = Math.max(x0, Math.min(x, x1));
|
|
58
|
+
const closestY = Math.max(y0, Math.min(y, y1));
|
|
59
|
+
const quadDist = Math.hypot(closestX - x, closestY - y);
|
|
60
|
+
|
|
61
|
+
// Prune: if the closest edge of this quad is beyond searchRadius, skip
|
|
62
|
+
if (quadDist > searchRadius) return true;
|
|
63
|
+
|
|
64
|
+
// Check leaf data
|
|
65
|
+
if (!node.length) {
|
|
66
|
+
let current = node;
|
|
67
|
+
do {
|
|
68
|
+
const d = current.data;
|
|
69
|
+
if (d) {
|
|
70
|
+
const dist = Math.hypot(d.x - x, d.y - y);
|
|
71
|
+
// Effective distance: subtract the node's radius.
|
|
72
|
+
// If we're inside the circle, effective distance is 0.
|
|
73
|
+
const effectiveDist = Math.max(0, dist - d.radius);
|
|
74
|
+
if (effectiveDist <= maxDistance && effectiveDist < bestEffectiveDist) {
|
|
75
|
+
bestEffectiveDist = effectiveDist;
|
|
76
|
+
best = d;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} while ((current = current.next!));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Find all nodes whose centers fall within the given rectangle. */
|
|
89
|
+
findInRect(x1: number, y1: number, x2: number, y2: number): PositionedNode[] {
|
|
90
|
+
if (!this.tree) return [];
|
|
91
|
+
|
|
92
|
+
const minX = Math.min(x1, x2);
|
|
93
|
+
const minY = Math.min(y1, y2);
|
|
94
|
+
const maxX = Math.max(x1, x2);
|
|
95
|
+
const maxY = Math.max(y1, y2);
|
|
96
|
+
|
|
97
|
+
const results: PositionedNode[] = [];
|
|
98
|
+
|
|
99
|
+
this.tree.visit((node, qx0, qy0, qx1, qy1) => {
|
|
100
|
+
// If quad doesn't overlap the search rect, skip
|
|
101
|
+
if (qx0 > maxX || qx1 < minX || qy0 > maxY || qy1 < minY) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check leaf nodes
|
|
106
|
+
if (!node.length) {
|
|
107
|
+
let current = node;
|
|
108
|
+
do {
|
|
109
|
+
const d = current.data;
|
|
110
|
+
if (d && d.x >= minX && d.x <= maxX && d.y >= minY && d.y <= maxY) {
|
|
111
|
+
results.push(d);
|
|
112
|
+
}
|
|
113
|
+
} while ((current = current.next!));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the graph adapter.
|
|
3
|
+
*
|
|
4
|
+
* These extend the engine's compiled types with positional information set by
|
|
5
|
+
* the force simulation at runtime. The engine produces CompiledGraphNode/Edge
|
|
6
|
+
* objects with visual properties but no x/y coords. After simulation, we get
|
|
7
|
+
* PositionedNode/Edge that the canvas renderer can draw.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
11
|
+
import type { CompiledGraphEdge, CompiledGraphNode } from '@opendata-ai/openchart-engine';
|
|
12
|
+
|
|
13
|
+
/** A compiled node with simulation-assigned x/y position. */
|
|
14
|
+
export interface PositionedNode extends CompiledGraphNode {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A compiled edge with resolved source/target screen positions. */
|
|
20
|
+
export interface PositionedEdge extends CompiledGraphEdge {
|
|
21
|
+
sourceX: number;
|
|
22
|
+
sourceY: number;
|
|
23
|
+
targetX: number;
|
|
24
|
+
targetY: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Complete render state passed to the canvas renderer each frame.
|
|
29
|
+
*
|
|
30
|
+
* Assembled by the graph mount from simulation positions, interaction state,
|
|
31
|
+
* and theme. The renderer is stateless -- it draws whatever this says.
|
|
32
|
+
*/
|
|
33
|
+
export interface GraphRenderState {
|
|
34
|
+
nodes: PositionedNode[];
|
|
35
|
+
edges: PositionedEdge[];
|
|
36
|
+
transform: { x: number; y: number; k: number };
|
|
37
|
+
hoveredNodeId: string | null;
|
|
38
|
+
selectedNodeIds: Set<string>;
|
|
39
|
+
adjacencyMap: Map<string, Set<string>>;
|
|
40
|
+
theme: ResolvedTheme;
|
|
41
|
+
searchMatches: Set<string> | null;
|
|
42
|
+
/** True during active pan/zoom gestures. Renderer skips labels and glow. */
|
|
43
|
+
isGesturing: boolean;
|
|
44
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message protocol between the main thread and the simulation Web Worker.
|
|
3
|
+
*
|
|
4
|
+
* Defines the shape of messages going in both directions, plus the internal
|
|
5
|
+
* node/edge types the simulation works with. These are intentionally simple
|
|
6
|
+
* and independent from the engine's compiled types so the worker can be
|
|
7
|
+
* built as a standalone IIFE without workspace imports.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Simulation data shapes (used internally by worker + sync fallback)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Minimal node shape for the force simulation. */
|
|
15
|
+
export interface SimNode {
|
|
16
|
+
id: string;
|
|
17
|
+
x?: number;
|
|
18
|
+
y?: number;
|
|
19
|
+
radius: number;
|
|
20
|
+
community?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal edge shape for the force simulation. */
|
|
24
|
+
export interface SimEdge {
|
|
25
|
+
source: string;
|
|
26
|
+
target: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Inline simulation config (mirrors engine SimulationConfig). */
|
|
30
|
+
export interface WorkerSimulationConfig {
|
|
31
|
+
chargeStrength: number;
|
|
32
|
+
linkDistance: number;
|
|
33
|
+
clustering: { field: string; strength: number } | null;
|
|
34
|
+
alphaDecay: number;
|
|
35
|
+
velocityDecay: number;
|
|
36
|
+
collisionRadius: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Main -> Worker messages
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export type WorkerInMessage =
|
|
44
|
+
| {
|
|
45
|
+
type: 'init';
|
|
46
|
+
nodes: SimNode[];
|
|
47
|
+
edges: SimEdge[];
|
|
48
|
+
config: WorkerSimulationConfig;
|
|
49
|
+
}
|
|
50
|
+
| { type: 'reheat'; alpha?: number }
|
|
51
|
+
| { type: 'pin'; nodeId: string; x: number; y: number }
|
|
52
|
+
| { type: 'unpin'; nodeId: string }
|
|
53
|
+
| { type: 'drag'; nodeId: string; x: number; y: number }
|
|
54
|
+
| { type: 'stop' };
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Worker -> Main messages
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export type WorkerOutMessage =
|
|
61
|
+
| {
|
|
62
|
+
type: 'positions';
|
|
63
|
+
nodes: Array<{ id: string; x: number; y: number }>;
|
|
64
|
+
alpha: number;
|
|
65
|
+
}
|
|
66
|
+
| { type: 'settled' }
|
|
67
|
+
| { type: 'error'; message: string };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable zoom transform for graph pan/zoom.
|
|
3
|
+
*
|
|
4
|
+
* Provides coordinate conversion between screen space (canvas pixels)
|
|
5
|
+
* and graph space (simulation coordinates). All mutations return new
|
|
6
|
+
* instances rather than modifying in place.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PositionedNode } from './types';
|
|
10
|
+
|
|
11
|
+
export class ZoomTransform {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly x: number,
|
|
14
|
+
readonly y: number,
|
|
15
|
+
readonly k: number,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
/** Convert screen coordinates to graph coordinates. */
|
|
19
|
+
screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
|
20
|
+
return {
|
|
21
|
+
x: (sx - this.x) / this.k,
|
|
22
|
+
y: (sy - this.y) / this.k,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Convert graph coordinates to screen coordinates. */
|
|
27
|
+
graphToScreen(gx: number, gy: number): { x: number; y: number } {
|
|
28
|
+
return {
|
|
29
|
+
x: gx * this.k + this.x,
|
|
30
|
+
y: gy * this.k + this.y,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Zoom to a target scale, keeping the given screen-space pivot
|
|
36
|
+
* point fixed (content under the cursor stays under the cursor).
|
|
37
|
+
*/
|
|
38
|
+
zoomAt(targetK: number, pivotX: number, pivotY: number): ZoomTransform {
|
|
39
|
+
// The graph point under the pivot should remain at the same screen position.
|
|
40
|
+
// Before: pivotX = gx * k + x => gx = (pivotX - x) / k
|
|
41
|
+
// After: pivotX = gx * targetK + newX => newX = pivotX - gx * targetK
|
|
42
|
+
const gx = (pivotX - this.x) / this.k;
|
|
43
|
+
const gy = (pivotY - this.y) / this.k;
|
|
44
|
+
return new ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pan by a screen-space delta. */
|
|
48
|
+
pan(dx: number, dy: number): ZoomTransform {
|
|
49
|
+
return new ZoomTransform(this.x + dx, this.y + dy, this.k);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute a transform that fits all nodes within the given canvas
|
|
54
|
+
* dimensions with the specified padding.
|
|
55
|
+
*/
|
|
56
|
+
static fitBounds(
|
|
57
|
+
nodes: PositionedNode[],
|
|
58
|
+
canvasW: number,
|
|
59
|
+
canvasH: number,
|
|
60
|
+
padding: number = 40,
|
|
61
|
+
): ZoomTransform {
|
|
62
|
+
if (nodes.length === 0) {
|
|
63
|
+
return ZoomTransform.identity();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let minX = Infinity;
|
|
67
|
+
let minY = Infinity;
|
|
68
|
+
let maxX = -Infinity;
|
|
69
|
+
let maxY = -Infinity;
|
|
70
|
+
|
|
71
|
+
for (const n of nodes) {
|
|
72
|
+
const r = n.radius;
|
|
73
|
+
if (n.x - r < minX) minX = n.x - r;
|
|
74
|
+
if (n.y - r < minY) minY = n.y - r;
|
|
75
|
+
if (n.x + r > maxX) maxX = n.x + r;
|
|
76
|
+
if (n.y + r > maxY) maxY = n.y + r;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const graphW = maxX - minX;
|
|
80
|
+
const graphH = maxY - minY;
|
|
81
|
+
|
|
82
|
+
if (graphW === 0 && graphH === 0) {
|
|
83
|
+
// All nodes at the same point; just center
|
|
84
|
+
return new ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const availW = canvasW - padding * 2;
|
|
88
|
+
const availH = canvasH - padding * 2;
|
|
89
|
+
const k = Math.min(availW / graphW, availH / graphH);
|
|
90
|
+
|
|
91
|
+
// Center the graph in the canvas
|
|
92
|
+
const cx = (minX + maxX) / 2;
|
|
93
|
+
const cy = (minY + maxY) / 2;
|
|
94
|
+
const tx = canvasW / 2 - cx * k;
|
|
95
|
+
const ty = canvasH / 2 - cy * k;
|
|
96
|
+
|
|
97
|
+
return new ZoomTransform(tx, ty, k);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Identity transform (no pan, no zoom). */
|
|
101
|
+
static identity(): ZoomTransform {
|
|
102
|
+
return new ZoomTransform(0, 0, 1);
|
|
103
|
+
}
|
|
104
|
+
}
|