@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. 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
+ }