@visualli/core 0.1.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.
Files changed (43) hide show
  1. package/README.md +263 -0
  2. package/dist/index.cjs +2325 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +1181 -0
  5. package/dist/index.d.ts +1181 -0
  6. package/dist/index.js +2119 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +80 -0
  9. package/src/animations/constants.ts +72 -0
  10. package/src/animations/easing.ts +70 -0
  11. package/src/animations/index.ts +2 -0
  12. package/src/config/blobShapes.ts +146 -0
  13. package/src/config/textScaling.ts +54 -0
  14. package/src/constants/design.ts +281 -0
  15. package/src/constants/index.ts +3 -0
  16. package/src/constants/performanceConstants.ts +157 -0
  17. package/src/constants/renderConfig.ts +134 -0
  18. package/src/index.ts +18 -0
  19. package/src/layout/circularLayout.ts +95 -0
  20. package/src/layout/index.ts +3 -0
  21. package/src/layout/layoutUtils.ts +132 -0
  22. package/src/layout/linearLayout.ts +84 -0
  23. package/src/parser/configUtils.ts +129 -0
  24. package/src/parser/index.ts +4 -0
  25. package/src/parser/middleware.ts +24 -0
  26. package/src/parser/mindmapUtils.ts +76 -0
  27. package/src/parser/visualliConverter.ts +328 -0
  28. package/src/parser/visualliParser.ts +191 -0
  29. package/src/performance/index.ts +1 -0
  30. package/src/performance/performanceMonitor.ts +145 -0
  31. package/src/rendering/culling.ts +71 -0
  32. package/src/spatial/index.ts +1 -0
  33. package/src/spatial/spatialIndex.ts +83 -0
  34. package/src/stores/index.ts +61 -0
  35. package/src/types/document.ts +15 -0
  36. package/src/types/index.ts +17 -0
  37. package/src/types/layer.ts +67 -0
  38. package/src/types/meta.ts +9 -0
  39. package/src/types/mindmap.ts +136 -0
  40. package/src/types/schema.ts +36 -0
  41. package/src/utils/navigation.ts +161 -0
  42. package/src/viewport/index.ts +1 -0
  43. package/src/viewport/viewportUtils.ts +195 -0
@@ -0,0 +1,161 @@
1
+ import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection } from '../types/index.js';
2
+ import { ZOOM_MAX, ZOOM_NAV_OUT_THRESHOLD } from '../constants/performanceConstants.js';
3
+
4
+ // ── Connection helpers ────────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Returns all top-level connections that originate from the given layer.
8
+ * A "top-level connection" links two nodes that both live in `layerId`.
9
+ */
10
+ export function getConnectionsForLayer(
11
+ doc: VisualliDocument,
12
+ layerId: string,
13
+ ): MindMapConnection[] {
14
+ const layer = doc.layers.get(layerId);
15
+ if (!layer) return [];
16
+ // Convert schema LayerConnection to simplified mindmap MindMapConnection
17
+ return layer.connections.map(c => ({
18
+ from: c.from,
19
+ to: c.to,
20
+ level: layer.level,
21
+ label: c.data?.label,
22
+ } satisfies MindMapConnection));
23
+ }
24
+
25
+ /**
26
+ * Given a node in the current layer, returns the child layer (if any) that the
27
+ * node "owns" — i.e. the node is the entry-point into that child layer.
28
+ */
29
+ export function getChildLayerForNode(
30
+ doc: VisualliDocument,
31
+ nodeId: string,
32
+ currentLayerId: string,
33
+ ): VisualliLayer | null {
34
+ // Walk all layers looking for one whose `parentNodeId` matches
35
+ for (const [, layer] of doc.layers) {
36
+ if (layer.parentLayerId === currentLayerId && layer.parentNodeId === nodeId) {
37
+ return layer;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Returns the layer to navigate INTO when double-clicking `nodeId` in `currentLayerId`.
45
+ * Returns null when the node has no child layer.
46
+ */
47
+ export function getLayerForNavigation(
48
+ doc: VisualliDocument,
49
+ nodeId: string,
50
+ currentLayerId: string,
51
+ ): VisualliLayer | null {
52
+ return getChildLayerForNode(doc, nodeId, currentLayerId);
53
+ }
54
+
55
+ // ── Viewport fitting math ─────────────────────────────────────────────────────
56
+
57
+ export interface FitResult {
58
+ centerX: number;
59
+ centerY: number;
60
+ zoomLevel: number;
61
+ }
62
+
63
+ /**
64
+ * Calculates the zoom level needed to fit all `nodes` inside a canvas of
65
+ * `(canvasWidth, canvasHeight)`.
66
+ *
67
+ * @param paddingFraction - Fractional padding on each side (0.15 = 15%).
68
+ * Pixel-padding variant still available when a value >=1 is passed.
69
+ */
70
+ export function calculateFitZoom(
71
+ nodes: FlatNode[],
72
+ canvasWidth: number,
73
+ canvasHeight: number,
74
+ padding = 0.15,
75
+ ): number {
76
+ if (nodes.length === 0) return 1;
77
+
78
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
79
+ for (const n of nodes) {
80
+ // Use the actual node boundaries (center +/- half-width/height)
81
+ minX = Math.min(minX, n.x - n.width / 2);
82
+ minY = Math.min(minY, n.y - n.height / 2);
83
+ maxX = Math.max(maxX, n.x + n.width / 2);
84
+ maxY = Math.max(maxY, n.y + n.height / 2);
85
+ }
86
+
87
+ const rawW = maxX - minX;
88
+ const rawH = maxY - minY;
89
+
90
+ // Minimum spread — single isolated node should appear comfortably
91
+ // Matches visualli.ai's spread logic for single nodes
92
+ const spreadW = nodes.length === 1 ? Math.max(rawW, 600) : rawW;
93
+ const spreadH = nodes.length === 1 ? Math.max(rawH, 400) : rawH;
94
+
95
+ const zoomX = (canvasWidth * (1 - padding)) / spreadW;
96
+ const zoomY = (canvasHeight * (1 - padding)) / spreadH;
97
+
98
+ const z = Math.min(zoomX, zoomY);
99
+
100
+ // Scale back 20% so nodes aren't edge-to-edge, clamp to at least
101
+ // ZOOM_NAV_OUT_THRESHOLD so arriving at the layer never immediately
102
+ // re-triggers the zoom-out transition.
103
+ return Math.max(z * 0.80, ZOOM_NAV_OUT_THRESHOLD);
104
+ }
105
+
106
+ /**
107
+ * Calculates the world-space center point that puts all `nodes` in the middle
108
+ * of the viewport. Matches the visualli.ai `calculateFitCenter` approach:
109
+ * uses bounding-box midpoint rather than centroid so the layout is always
110
+ * geometrically centred.
111
+ */
112
+ export function calculateFitCenter(nodes: FlatNode[]): { x: number; y: number } {
113
+ if (nodes.length === 0) return { x: 0, y: 0 };
114
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
115
+ for (const n of nodes) {
116
+ minX = Math.min(minX, n.x - n.width / 2);
117
+ minY = Math.min(minY, n.y - n.height / 2);
118
+ maxX = Math.max(maxX, n.x + n.width / 2);
119
+ maxY = Math.max(maxY, n.y + n.height / 2);
120
+ }
121
+ return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
122
+ }
123
+
124
+ // ── Container helpers ────────────────────────────────────────────────────────
125
+
126
+ // Inline the container shape here to avoid a circular import with KonvaContainerLayer.
127
+ export interface ContainerGroupInfo {
128
+ id: string;
129
+ label?: string;
130
+ nodeIds: string[];
131
+ level: number;
132
+ }
133
+
134
+ /**
135
+ * Returns the container groups for the given layer, normalising both the
136
+ * nested-data format `{data:{label,formation}}` and the flat format `{label}`.
137
+ */
138
+ export function getContainersForLayer(
139
+ doc: VisualliDocument,
140
+ layerId: string,
141
+ ): ContainerGroupInfo[] {
142
+ const layer = doc.layers.get(layerId);
143
+ if (!layer) return [];
144
+ return layer.containers.map(c => {
145
+ // Schema Container has nested data.label structure
146
+ const label = c.data?.label ?? c.id;
147
+ return { id: c.id, label, nodeIds: c.nodes, level: layer.level };
148
+ });
149
+ }
150
+
151
+ /** Convenience: returns both center and zoom for a single `fitView` call. */
152
+ export function calculateFitView(
153
+ nodes: FlatNode[],
154
+ canvasWidth: number,
155
+ canvasHeight: number,
156
+ ): FitResult {
157
+ const center = calculateFitCenter(nodes);
158
+ const zoom = calculateFitZoom(nodes, canvasWidth, canvasHeight);
159
+ return { centerX: center.x, centerY: center.y, zoomLevel: zoom };
160
+ }
161
+
@@ -0,0 +1 @@
1
+ export * from './viewportUtils.js';
@@ -0,0 +1,195 @@
1
+ // ─── Viewport Utilities ───────────────────────────────────────────────────────
2
+ //
3
+ // Pure functions for viewport bounds calculation, coordinate transforms,
4
+ // pan/zoom operations, and node visibility queries.
5
+
6
+ import type { ViewportState } from '../types/mindmap.js';
7
+ import {
8
+ VIEWPORT_MARGIN,
9
+ DEFAULT_CANVAS_WIDTH,
10
+ DEFAULT_CANVAS_HEIGHT,
11
+ ZOOM_MIN,
12
+ ZOOM_MAX,
13
+ } from '../constants/performanceConstants.js';
14
+
15
+ // ── Viewport Bounds ───────────────────────────────────────────────────────────
16
+
17
+ export interface WorldBounds {
18
+ minX: number;
19
+ minY: number;
20
+ maxX: number;
21
+ maxY: number;
22
+ }
23
+
24
+ /**
25
+ * Calculate the visible world-space bounding box including a margin buffer.
26
+ */
27
+ export function calculateViewportBounds(
28
+ viewport: ViewportState,
29
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
30
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
31
+ ): WorldBounds {
32
+ const worldW = canvasWidth / viewport.zoomLevel;
33
+ const worldH = canvasHeight / viewport.zoomLevel;
34
+ const mx = worldW * VIEWPORT_MARGIN;
35
+ const my = worldH * VIEWPORT_MARGIN;
36
+ return {
37
+ minX: viewport.centerX - worldW / 2 - mx,
38
+ minY: viewport.centerY - worldH / 2 - my,
39
+ maxX: viewport.centerX + worldW / 2 + mx,
40
+ maxY: viewport.centerY + worldH / 2 + my,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Return a new ViewportState with `visibleBounds` recalculated.
46
+ */
47
+ export function recalculateViewportBounds(
48
+ viewport: ViewportState,
49
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
50
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
51
+ ): ViewportState {
52
+ return {
53
+ ...viewport,
54
+ visibleBounds: calculateViewportBounds(viewport, canvasWidth, canvasHeight),
55
+ };
56
+ }
57
+
58
+ // ── Coordinate Transforms ─────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Convert world-space coordinates to screen-space pixel coordinates.
62
+ */
63
+ export function worldToScreen(
64
+ worldX: number,
65
+ worldY: number,
66
+ viewport: ViewportState,
67
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
68
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
69
+ ): { x: number; y: number } {
70
+ return {
71
+ x: (worldX - viewport.centerX) * viewport.zoomLevel + canvasWidth / 2,
72
+ y: (worldY - viewport.centerY) * viewport.zoomLevel + canvasHeight / 2,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Convert screen-space pixel coordinates to world-space coordinates.
78
+ */
79
+ export function screenToWorld(
80
+ screenX: number,
81
+ screenY: number,
82
+ viewport: ViewportState,
83
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
84
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
85
+ ): { x: number; y: number } {
86
+ return {
87
+ x: (screenX - canvasWidth / 2) / viewport.zoomLevel + viewport.centerX,
88
+ y: (screenY - canvasHeight / 2) / viewport.zoomLevel + viewport.centerY,
89
+ };
90
+ }
91
+
92
+ // ── Pan / Zoom ────────────────────────────────────────────────────────────────
93
+
94
+ /** Clamp zoom to [ZOOM_MIN, ZOOM_MAX]. */
95
+ export function clampZoom(zoom: number): number {
96
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
97
+ }
98
+
99
+ /**
100
+ * Pan the viewport by a screen-space delta (positive = right/down).
101
+ */
102
+ export function panViewport(
103
+ deltaX: number,
104
+ deltaY: number,
105
+ viewport: ViewportState,
106
+ ): ViewportState {
107
+ return {
108
+ ...viewport,
109
+ centerX: viewport.centerX - deltaX / viewport.zoomLevel,
110
+ centerY: viewport.centerY - deltaY / viewport.zoomLevel,
111
+ };
112
+ }
113
+
114
+ /** Teleport the viewport centre to the given world coordinates. */
115
+ export function setViewportCenter(
116
+ x: number,
117
+ y: number,
118
+ viewport: ViewportState,
119
+ ): ViewportState {
120
+ return { ...viewport, centerX: x, centerY: y };
121
+ }
122
+
123
+ /**
124
+ * Zoom the viewport by a multiplicative delta, optionally around a pivot point.
125
+ *
126
+ * @param delta - Multiplier delta, e.g. 0.1 for +10% zoom
127
+ * @param pivotX - Optional screen-space X pivot
128
+ * @param pivotY - Optional screen-space Y pivot
129
+ */
130
+ export function zoomViewport(
131
+ delta: number,
132
+ viewport: ViewportState,
133
+ pivotX?: number,
134
+ pivotY?: number,
135
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
136
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
137
+ ): ViewportState {
138
+ const newZoom = clampZoom(viewport.zoomLevel * (1 + delta));
139
+
140
+ let cx = viewport.centerX;
141
+ let cy = viewport.centerY;
142
+
143
+ if (pivotX !== undefined && pivotY !== undefined) {
144
+ const worldPivot = screenToWorld(pivotX, pivotY, viewport, canvasWidth, canvasHeight);
145
+ cx = worldPivot.x - (pivotX - canvasWidth / 2) / newZoom;
146
+ cy = worldPivot.y - (pivotY - canvasHeight / 2) / newZoom;
147
+ }
148
+
149
+ return { ...viewport, centerX: cx, centerY: cy, zoomLevel: newZoom };
150
+ }
151
+
152
+ /**
153
+ * Create a viewport centred on a node (world coordinates) at the given zoom level.
154
+ */
155
+ export function focusOnNode(
156
+ nodeX: number,
157
+ nodeY: number,
158
+ zoom: number,
159
+ ): ViewportState {
160
+ const clamped = clampZoom(zoom);
161
+ return {
162
+ centerX: nodeX,
163
+ centerY: nodeY,
164
+ zoomLevel: clamped,
165
+ rotation: 0,
166
+ visibleBounds: calculateViewportBounds(
167
+ { centerX: nodeX, centerY: nodeY, zoomLevel: clamped, rotation: 0, visibleBounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 } },
168
+ ),
169
+ };
170
+ }
171
+
172
+ // ── Visibility ────────────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Test whether a world-space AABB intersects the viewport's visible bounds.
176
+ */
177
+ export function isNodeInViewport(
178
+ nodeX: number,
179
+ nodeY: number,
180
+ nodeWidth: number,
181
+ nodeHeight: number,
182
+ viewport: ViewportState,
183
+ canvasWidth = DEFAULT_CANVAS_WIDTH,
184
+ canvasHeight = DEFAULT_CANVAS_HEIGHT,
185
+ ): boolean {
186
+ const bounds = calculateViewportBounds(viewport, canvasWidth, canvasHeight);
187
+ const hw = nodeWidth / 2;
188
+ const hh = nodeHeight / 2;
189
+ return !(
190
+ nodeX + hw < bounds.minX ||
191
+ nodeX - hw > bounds.maxX ||
192
+ nodeY + hh < bounds.minY ||
193
+ nodeY - hh > bounds.maxY
194
+ );
195
+ }