@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.
- package/README.md +263 -0
- package/dist/index.cjs +2325 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1181 -0
- package/dist/index.d.ts +1181 -0
- package/dist/index.js +2119 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
- package/src/animations/constants.ts +72 -0
- package/src/animations/easing.ts +70 -0
- package/src/animations/index.ts +2 -0
- package/src/config/blobShapes.ts +146 -0
- package/src/config/textScaling.ts +54 -0
- package/src/constants/design.ts +281 -0
- package/src/constants/index.ts +3 -0
- package/src/constants/performanceConstants.ts +157 -0
- package/src/constants/renderConfig.ts +134 -0
- package/src/index.ts +18 -0
- package/src/layout/circularLayout.ts +95 -0
- package/src/layout/index.ts +3 -0
- package/src/layout/layoutUtils.ts +132 -0
- package/src/layout/linearLayout.ts +84 -0
- package/src/parser/configUtils.ts +129 -0
- package/src/parser/index.ts +4 -0
- package/src/parser/middleware.ts +24 -0
- package/src/parser/mindmapUtils.ts +76 -0
- package/src/parser/visualliConverter.ts +328 -0
- package/src/parser/visualliParser.ts +191 -0
- package/src/performance/index.ts +1 -0
- package/src/performance/performanceMonitor.ts +145 -0
- package/src/rendering/culling.ts +71 -0
- package/src/spatial/index.ts +1 -0
- package/src/spatial/spatialIndex.ts +83 -0
- package/src/stores/index.ts +61 -0
- package/src/types/document.ts +15 -0
- package/src/types/index.ts +17 -0
- package/src/types/layer.ts +67 -0
- package/src/types/meta.ts +9 -0
- package/src/types/mindmap.ts +136 -0
- package/src/types/schema.ts +36 -0
- package/src/utils/navigation.ts +161 -0
- package/src/viewport/index.ts +1 -0
- 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
|
+
}
|