@visualli/react 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 +335 -0
- package/dist/index.cjs +3262 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +375 -0
- package/dist/index.d.ts +375 -0
- package/dist/index.js +3289 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/VisualliCanvas.tsx +1087 -0
- package/src/VisualliRenderer.tsx +427 -0
- package/src/animations/index.ts +2 -0
- package/src/animations/konvaLayerTransition.ts +223 -0
- package/src/components/KonvaContainer.tsx +255 -0
- package/src/components/KonvaContainerLayer.tsx +63 -0
- package/src/components/KonvaEdge.tsx +254 -0
- package/src/components/KonvaEdgeLayer.tsx +72 -0
- package/src/components/KonvaNode.tsx +177 -0
- package/src/components/KonvaNodeLayer.tsx +61 -0
- package/src/components/KonvaStage.tsx +80 -0
- package/src/components/NavigationStack.tsx +184 -0
- package/src/components/SketchyBoxKonva.tsx +121 -0
- package/src/components/ZoomControls.tsx +110 -0
- package/src/config/blobShapes.ts +204 -0
- package/src/config/index.ts +21 -0
- package/src/config/textScaling.ts +69 -0
- package/src/context/VisualliContext.tsx +50 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useKonvaLayerTransition.ts +221 -0
- package/src/hooks/useKonvaRenderer.ts +185 -0
- package/src/hooks/useViewportNodes.ts +59 -0
- package/src/index.ts +81 -0
- package/src/konvaCompat.ts +29 -0
- package/src/stores/index.ts +3 -0
- package/src/stores/stores.ts +17 -0
- package/src/stores/useNodeStore.ts +101 -0
- package/src/stores/useViewportStore.ts +95 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/layerNavigation.ts +15 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// ─── VisualliRenderer ────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The single public-facing component for rendering a .visualli document.
|
|
4
|
+
//
|
|
5
|
+
// <VisualliRenderer visualliString={str} theme="dark" width="100%" height="600px" />
|
|
6
|
+
// <VisualliRenderer visualliFile={file} theme="auto" width={800} height={600} />
|
|
7
|
+
// <VisualliRenderer document={doc} theme="auto" width={800} height={600} />
|
|
8
|
+
//
|
|
9
|
+
// Handles:
|
|
10
|
+
// • theme: 'dark' | 'light' | 'auto' — auto follows prefers-color-scheme
|
|
11
|
+
// • width / height as first-class props (CSS string or pixel number)
|
|
12
|
+
// • Empty state — no data prop provided
|
|
13
|
+
// • Loading state — while parsing a .visualli file/string
|
|
14
|
+
// • Error state — parse failure with message
|
|
15
|
+
// • useWorker — offload parsing to a Web Worker (default: false)
|
|
16
|
+
// • Delegates all canvas/navigation/zoom logic to VisualliCanvas
|
|
17
|
+
|
|
18
|
+
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|
19
|
+
import type { VisualliDocument, VisualliLayer, FlatNode } from '@visualli/core';
|
|
20
|
+
import { parseVisualliFile } from '@visualli/core';
|
|
21
|
+
import VisualliCanvas from './VisualliCanvas';
|
|
22
|
+
|
|
23
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type VisualliTheme = 'dark' | 'light' | 'auto';
|
|
26
|
+
|
|
27
|
+
export interface VisualliRendererProps {
|
|
28
|
+
/**
|
|
29
|
+
* Raw JSONL string — the content of a .visualli file.
|
|
30
|
+
* Simply pass the string data directly, no preprocessing needed.
|
|
31
|
+
* Example: visualliString="{\"type\":\"meta\"...}\n{\"type\":\"layer\"...}"
|
|
32
|
+
*/
|
|
33
|
+
visualliString?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A .visualli file — can be:
|
|
37
|
+
* - File object (e.g. from <input type="file">)
|
|
38
|
+
* - String path to file (e.g. '/data/myfile.visualli' or './src/data/data.visualli')
|
|
39
|
+
* The renderer automatically fetches, reads, and parses it.
|
|
40
|
+
* No user code required for conversion or processing.
|
|
41
|
+
*/
|
|
42
|
+
visualliFile?: File | string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* When true, parsing is offloaded to a Web Worker so the main thread never
|
|
46
|
+
* blocks during JSON.parse. Recommended for large documents (>500 nodes).
|
|
47
|
+
* Has no effect when `document` is supplied (already parsed).
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
useWorker?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Colour theme.
|
|
54
|
+
* - 'dark' → always dark background
|
|
55
|
+
* - 'light' → always light background
|
|
56
|
+
* - 'auto' → follows the OS/browser prefers-color-scheme
|
|
57
|
+
* @default 'light'
|
|
58
|
+
*/
|
|
59
|
+
theme?: VisualliTheme;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enable chromatic immersion background effect.
|
|
63
|
+
* When true:
|
|
64
|
+
* - Root layer (level 0) shows base background color
|
|
65
|
+
* - Child layers show parent node's color as semi-transparent background
|
|
66
|
+
* @default false
|
|
67
|
+
*/
|
|
68
|
+
chromaticImmersion?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Width of the renderer. Accepts any CSS length string ('100%', '800px', '100vw')
|
|
72
|
+
* or a plain number treated as pixels.
|
|
73
|
+
* @default '100%'
|
|
74
|
+
*/
|
|
75
|
+
width?: string | number;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Height of the renderer. Accepts any CSS length string ('100%', '600px', '100vh')
|
|
79
|
+
* or a plain number treated as pixels.
|
|
80
|
+
* @default '100%'
|
|
81
|
+
*/
|
|
82
|
+
height?: string | number;
|
|
83
|
+
|
|
84
|
+
/** Additional CSS class on the root element. */
|
|
85
|
+
className?: string;
|
|
86
|
+
|
|
87
|
+
/** Additional inline styles merged onto the root element. */
|
|
88
|
+
style?: React.CSSProperties;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── System dark-mode hook ─────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function useSystemDark(): boolean {
|
|
94
|
+
const mq = typeof window !== 'undefined'
|
|
95
|
+
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
const [dark, setDark] = useState<boolean>(mq?.matches ?? false);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!mq) return;
|
|
102
|
+
const handler = (e: MediaQueryListEvent) => setDark(e.matches);
|
|
103
|
+
mq.addEventListener('change', handler);
|
|
104
|
+
return () => mq.removeEventListener('change', handler);
|
|
105
|
+
}, [mq]);
|
|
106
|
+
|
|
107
|
+
return dark;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Font Injection Hook ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function useFontInjection() {
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (typeof document === 'undefined') return;
|
|
115
|
+
|
|
116
|
+
const links = [
|
|
117
|
+
'https://fonts.googleapis.com',
|
|
118
|
+
'https://fonts.gstatic.com',
|
|
119
|
+
'https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Nunito+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&display=swap',
|
|
120
|
+
'https://fonts.googleapis.com/css2?family=Playpen+Sans:wght@300;400;600;800&family=Story+Script:wght@400&display=swap'
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
links.forEach((href, i) => {
|
|
124
|
+
const id = `visualli-font-${i}`;
|
|
125
|
+
if (!document.getElementById(id)) {
|
|
126
|
+
const link = document.createElement('link');
|
|
127
|
+
link.id = id;
|
|
128
|
+
link.rel = i < 2 ? 'preconnect' : 'stylesheet';
|
|
129
|
+
link.href = href;
|
|
130
|
+
if (i === 1) link.crossOrigin = 'anonymous';
|
|
131
|
+
document.head.appendChild(link);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}, []);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Empty / Loading / Error states ───────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
// ── UI State Components ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function EmptyState({ isDark }: { isDark: boolean }) {
|
|
142
|
+
const bg = isDark ? '#131311' : '#F5F3EF';
|
|
143
|
+
const fg = isDark ? 'rgba(240,237,230,0.35)' : 'rgba(26,26,24,0.35)';
|
|
144
|
+
const bd = isDark ? '#2A2A28' : '#DDD9D0';
|
|
145
|
+
const code = { fontFamily: 'monospace', fontSize: 12, padding: '1px 4px', borderRadius: 4, background: isDark ? '#222220' : '#E8E4DC', border: `1px solid ${bd}` };
|
|
146
|
+
return (
|
|
147
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
|
|
148
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke={fg} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
149
|
+
<rect x="10" y="6" width="28" height="36" rx="3" />
|
|
150
|
+
<line x1="16" y1="16" x2="32" y2="16" />
|
|
151
|
+
<line x1="16" y1="22" x2="32" y2="22" />
|
|
152
|
+
<line x1="16" y1="28" x2="26" y2="28" />
|
|
153
|
+
</svg>
|
|
154
|
+
<div style={{ textAlign: 'center', lineHeight: 1.7 }}>
|
|
155
|
+
<p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: isDark ? 'rgba(240,237,230,0.7)' : 'rgba(26,26,24,0.7)' }}>
|
|
156
|
+
No .visualli file provided
|
|
157
|
+
</p>
|
|
158
|
+
<p style={{ margin: '4px 0 0', fontSize: 13 }}>
|
|
159
|
+
Pass a <code style={code}>visualliFile</code> or <code style={code}>visualliString</code> prop.
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function LoadingState({ isDark }: { isDark: boolean }) {
|
|
167
|
+
const bg = isDark ? '#131311' : '#F5F3EF';
|
|
168
|
+
const fg = isDark ? 'rgba(240,237,230,0.5)' : 'rgba(26,26,24,0.5)';
|
|
169
|
+
const dot = isDark ? 'rgba(240,237,230,0.6)' : 'rgba(26,26,24,0.45)';
|
|
170
|
+
return (
|
|
171
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
|
|
172
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
173
|
+
{[0, 1, 2].map(i => (
|
|
174
|
+
<div key={i} style={{ width: 8, height: 8, borderRadius: '50%', background: dot, animation: `vr-pulse 1.2s ease-in-out ${i * 0.2}s infinite` }} />
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
<style>{`@keyframes vr-pulse{0%,80%,100%{opacity:.2;transform:scale(.9)}40%{opacity:1;transform:scale(1.1)}}`}</style>
|
|
178
|
+
<p style={{ margin: 0, fontSize: 14 }}>Loading .visualli file…</p>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ErrorState({ message, isDark }: { message: string; isDark: boolean }) {
|
|
184
|
+
const bg = isDark ? '#131311' : '#F5F3EF';
|
|
185
|
+
const bd = isDark ? '#3a1f1f' : '#F0CECE';
|
|
186
|
+
const card = isDark ? '#1e1212' : '#FFF5F5';
|
|
187
|
+
const head = isDark ? '#F87171' : '#DC2626';
|
|
188
|
+
const body = isDark ? 'rgba(248,113,113,0.8)' : 'rgba(180,40,40,0.8)';
|
|
189
|
+
return (
|
|
190
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', padding: '24px', background: bg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
|
|
191
|
+
<div style={{ maxWidth: 480, width: '100%', padding: '20px 24px', borderRadius: 12, border: `1px solid ${bd}`, background: card }}>
|
|
192
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
193
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={head} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
194
|
+
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
195
|
+
</svg>
|
|
196
|
+
<span style={{ fontSize: 15, fontWeight: 700, color: head }}>Failed to parse .visualli file</span>
|
|
197
|
+
</div>
|
|
198
|
+
<p style={{ margin: 0, fontSize: 13, color: body, lineHeight: 1.6, wordBreak: 'break-word', fontFamily: 'monospace' }}>{message}</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Inline worker script ──────────────────────────────────────────────────────
|
|
205
|
+
// Self-contained JSONL parser that mirrors parseVisualliFile.
|
|
206
|
+
// Runs in a Web Worker when useWorker=true; no external imports needed.
|
|
207
|
+
|
|
208
|
+
const WORKER_SCRIPT = `
|
|
209
|
+
self.onmessage = function(e) {
|
|
210
|
+
var content = e.data;
|
|
211
|
+
try {
|
|
212
|
+
var lines = content.trim().split('\\n');
|
|
213
|
+
var doc = {
|
|
214
|
+
meta: null,
|
|
215
|
+
layers: new Map(),
|
|
216
|
+
layersByLevel: new Map(),
|
|
217
|
+
rootLayer: null,
|
|
218
|
+
};
|
|
219
|
+
for (var i = 0; i < lines.length; i++) {
|
|
220
|
+
var line = lines[i].trim();
|
|
221
|
+
if (!line) continue;
|
|
222
|
+
var obj;
|
|
223
|
+
try { obj = JSON.parse(line); }
|
|
224
|
+
catch(err) { throw new Error('Failed to parse JSON at line ' + (i+1) + ': ' + err); }
|
|
225
|
+
if (!obj.type) throw new Error("Missing 'type' field at line " + (i+1));
|
|
226
|
+
if (obj.type === 'meta') {
|
|
227
|
+
doc.meta = obj;
|
|
228
|
+
} else if (obj.type === 'layer') {
|
|
229
|
+
doc.layers.set(obj.id, obj);
|
|
230
|
+
if (!doc.layersByLevel.has(obj.level)) doc.layersByLevel.set(obj.level, []);
|
|
231
|
+
doc.layersByLevel.get(obj.level).push(obj);
|
|
232
|
+
if (obj.level === 0) doc.rootLayer = obj;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!doc.meta) throw new Error('Missing required meta section');
|
|
236
|
+
if (!doc.rootLayer) throw new Error('Missing root layer (level 0)');
|
|
237
|
+
self.postMessage({ ok: true, doc: doc });
|
|
238
|
+
} catch(err) {
|
|
239
|
+
self.postMessage({ ok: false, message: err.message || String(err) });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
// Lazy-create worker URL once (module-level singleton).
|
|
245
|
+
let _workerUrl: string | null = null;
|
|
246
|
+
function getWorkerUrl(): string {
|
|
247
|
+
if (!_workerUrl) {
|
|
248
|
+
_workerUrl = URL.createObjectURL(new Blob([WORKER_SCRIPT], { type: 'text/javascript' }));
|
|
249
|
+
}
|
|
250
|
+
return _workerUrl;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Parse hook ────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
type ParseState =
|
|
256
|
+
| { status: 'idle' }
|
|
257
|
+
| { status: 'loading' }
|
|
258
|
+
| { status: 'ready'; doc: VisualliDocument }
|
|
259
|
+
| { status: 'error'; message: string };
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolves a raw string + optional File into a ParseState.
|
|
263
|
+
* When useWorker=true the JSON.parse work runs in a Web Worker so the main
|
|
264
|
+
* thread is never blocked, even for very large documents.
|
|
265
|
+
*/
|
|
266
|
+
function useAsyncParse(
|
|
267
|
+
content: string | undefined,
|
|
268
|
+
useWorker: boolean,
|
|
269
|
+
): ParseState {
|
|
270
|
+
const [state, setState] = useState<ParseState>({ status: 'idle' });
|
|
271
|
+
const workerRef = useRef<Worker | null>(null);
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!content) { setState({ status: 'idle' }); return; }
|
|
275
|
+
|
|
276
|
+
setState({ status: 'loading' });
|
|
277
|
+
let cancelled = false;
|
|
278
|
+
|
|
279
|
+
if (useWorker && typeof Worker !== 'undefined') {
|
|
280
|
+
// Terminate any previous worker
|
|
281
|
+
workerRef.current?.terminate();
|
|
282
|
+
const w = new Worker(getWorkerUrl());
|
|
283
|
+
workerRef.current = w;
|
|
284
|
+
|
|
285
|
+
w.onmessage = (e: MessageEvent) => {
|
|
286
|
+
if (cancelled) return;
|
|
287
|
+
if (e.data.ok) {
|
|
288
|
+
setState({ status: 'ready', doc: e.data.doc as VisualliDocument });
|
|
289
|
+
} else {
|
|
290
|
+
setState({ status: 'error', message: e.data.message });
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
w.onerror = (e: ErrorEvent) => {
|
|
294
|
+
if (!cancelled) setState({ status: 'error', message: e.message });
|
|
295
|
+
};
|
|
296
|
+
w.postMessage(content);
|
|
297
|
+
|
|
298
|
+
return () => {
|
|
299
|
+
cancelled = true;
|
|
300
|
+
w.terminate();
|
|
301
|
+
workerRef.current = null;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Main-thread path: defer via microtask so React paints the loading state first
|
|
306
|
+
Promise.resolve().then(() => {
|
|
307
|
+
if (cancelled) return;
|
|
308
|
+
try {
|
|
309
|
+
const doc = parseVisualliFile(content);
|
|
310
|
+
if (!cancelled) setState({ status: 'ready', doc });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (!cancelled) setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
return () => { cancelled = true; };
|
|
316
|
+
}, [content, useWorker]);
|
|
317
|
+
|
|
318
|
+
// Terminate worker on unmount
|
|
319
|
+
useEffect(() => () => { workerRef.current?.terminate(); }, []);
|
|
320
|
+
|
|
321
|
+
return state;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── File → string hook ────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
function useFileText(file: File | string | undefined): { text: string | undefined; error: string | undefined; loading: boolean } {
|
|
327
|
+
const [text, setText] = useState<string | undefined>(undefined);
|
|
328
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
329
|
+
const [loading, setLoading] = useState(false);
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!file) { setText(undefined); setError(undefined); setLoading(false); return; }
|
|
333
|
+
setLoading(true); setText(undefined); setError(undefined);
|
|
334
|
+
let cancelled = false;
|
|
335
|
+
|
|
336
|
+
// Handle string path - fetch the file
|
|
337
|
+
if (typeof file === 'string') {
|
|
338
|
+
fetch(file)
|
|
339
|
+
.then(response => {
|
|
340
|
+
if (!response.ok) throw new Error(`Failed to fetch file: ${response.statusText}`);
|
|
341
|
+
return response.text();
|
|
342
|
+
})
|
|
343
|
+
.then(t => {
|
|
344
|
+
if (!cancelled) { setText(t); setLoading(false); }
|
|
345
|
+
})
|
|
346
|
+
.catch((err: unknown) => {
|
|
347
|
+
if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setLoading(false); }
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// Handle File object - read as text
|
|
351
|
+
else {
|
|
352
|
+
file.text().then(t => {
|
|
353
|
+
if (!cancelled) { setText(t); setLoading(false); }
|
|
354
|
+
}).catch((err: unknown) => {
|
|
355
|
+
if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setLoading(false); }
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return () => { cancelled = true; };
|
|
360
|
+
}, [file]);
|
|
361
|
+
|
|
362
|
+
return { text, error, loading };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── VisualliRenderer ──────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
export default function VisualliRenderer({
|
|
368
|
+
visualliString,
|
|
369
|
+
visualliFile,
|
|
370
|
+
theme = 'light',
|
|
371
|
+
width = '100%',
|
|
372
|
+
height = '100%',
|
|
373
|
+
useWorker = true,
|
|
374
|
+
chromaticImmersion = false,
|
|
375
|
+
className,
|
|
376
|
+
style,
|
|
377
|
+
}: VisualliRendererProps) {
|
|
378
|
+
useFontInjection();
|
|
379
|
+
const systemDark = useSystemDark();
|
|
380
|
+
const isDark = theme === 'dark' ? true : theme === 'light' ? false : systemDark;
|
|
381
|
+
|
|
382
|
+
const cssWidth = typeof width === 'number' ? `${width}px` : width;
|
|
383
|
+
const cssHeight = typeof height === 'number' ? `${height}px` : height;
|
|
384
|
+
|
|
385
|
+
// Read File → text (no-op if not provided)
|
|
386
|
+
const { text: fileText, error: fileReadError, loading: fileReading } = useFileText(visualliFile);
|
|
387
|
+
|
|
388
|
+
// The raw JSONL string to parse: file takes priority over string prop
|
|
389
|
+
const rawContent = fileText ?? visualliString;
|
|
390
|
+
|
|
391
|
+
const parseState = useAsyncParse(rawContent, useWorker);
|
|
392
|
+
|
|
393
|
+
const resolvedDoc: VisualliDocument | null = useMemo(() => {
|
|
394
|
+
if (parseState.status === 'ready') return parseState.doc;
|
|
395
|
+
return null;
|
|
396
|
+
}, [parseState]);
|
|
397
|
+
|
|
398
|
+
const wrapperStyle: React.CSSProperties = { width: cssWidth, height: cssHeight, overflow: 'hidden', ...style };
|
|
399
|
+
|
|
400
|
+
// ── Empty state ─────────────────────────────────────────────────────────────
|
|
401
|
+
if (!visualliString && !visualliFile) {
|
|
402
|
+
return <div className={className} style={wrapperStyle}><EmptyState isDark={isDark} /></div>;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Loading state ───────────────────────────────────────────────────────────
|
|
406
|
+
if (fileReading || parseState.status === 'loading') {
|
|
407
|
+
return <div className={className} style={wrapperStyle}><LoadingState isDark={isDark} /></div>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Error state ─────────────────────────────────────────────────────────────
|
|
411
|
+
if (fileReadError || parseState.status === 'error') {
|
|
412
|
+
const msg = fileReadError ?? (parseState.status === 'error' ? parseState.message : 'Unknown error');
|
|
413
|
+
return <div className={className} style={wrapperStyle}><ErrorState message={msg} isDark={isDark} /></div>;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Canvas ──────────────────────────────────────────────────────────────────
|
|
417
|
+
return (
|
|
418
|
+
<div className={className} style={wrapperStyle}>
|
|
419
|
+
<VisualliCanvas
|
|
420
|
+
preParsedVisualli={resolvedDoc ?? undefined}
|
|
421
|
+
isDark={isDark}
|
|
422
|
+
chromaticImmersion={chromaticImmersion}
|
|
423
|
+
style={{ width: '100%', height: '100%' }}
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// ─── Konva Layer Transition Animator ─────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// rAF-driven animation engine for layer navigation:
|
|
4
|
+
// animateZoomIntoLayer — camera-dive into child layer
|
|
5
|
+
// animateZoomOutToParent — pull back to parent layer
|
|
6
|
+
// animateCrossFade — simple cross-fade between two viewports
|
|
7
|
+
//
|
|
8
|
+
// No React dependencies — can be instantiated once and reused.
|
|
9
|
+
|
|
10
|
+
import { easeInOutSine, easeOutCubic, easeZoomTransition } from '@visualli/core';
|
|
11
|
+
import { ANIMATION_PHASES } from '@visualli/core';
|
|
12
|
+
|
|
13
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface AnimatorViewport {
|
|
16
|
+
centerX: number;
|
|
17
|
+
centerY: number;
|
|
18
|
+
zoomLevel: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NodeBounds {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TransitionCallbacks {
|
|
29
|
+
onUpdate: (state: { viewport: AnimatorViewport; opacity: number; backgroundColor: string }) => void;
|
|
30
|
+
onComplete: () => void;
|
|
31
|
+
onPhaseChange?: (phase: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Color helpers ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface RGB { r: number; g: number; b: number }
|
|
37
|
+
|
|
38
|
+
function parseColor(color: string): RGB {
|
|
39
|
+
if (color.startsWith('#')) {
|
|
40
|
+
const h = color.slice(1);
|
|
41
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
|
|
42
|
+
}
|
|
43
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
44
|
+
return m ? { r: +m[1], g: +m[2], b: +m[3] } : { r: 255, g: 255, b: 255 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function interpolateRgb(a: RGB, b: RGB, t: number): string {
|
|
48
|
+
const e = easeInOutSine(t);
|
|
49
|
+
return `rgb(${(a.r + (b.r - a.r) * e + 0.5) | 0},${(a.g + (b.g - a.g) * e + 0.5) | 0},${(a.b + (b.b - a.b) * e + 0.5) | 0})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// (easeZoomTransition is imported from @visualli/core — createCubicBezier(0.2, 0.8, 0.2, 1))
|
|
53
|
+
|
|
54
|
+
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
|
|
55
|
+
|
|
56
|
+
// ── Animator class ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export class KonvaLayerTransitionAnimator {
|
|
59
|
+
private rafId: number | null = null;
|
|
60
|
+
private timers: ReturnType<typeof setTimeout>[] = [];
|
|
61
|
+
private _active = false;
|
|
62
|
+
|
|
63
|
+
get active(): boolean { return this._active; }
|
|
64
|
+
|
|
65
|
+
cancel(): void {
|
|
66
|
+
if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
|
|
67
|
+
this.timers.forEach(clearTimeout);
|
|
68
|
+
this.timers = [];
|
|
69
|
+
this._active = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Zoom into child layer ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
animateZoomIntoLayer(
|
|
75
|
+
current: AnimatorViewport,
|
|
76
|
+
targetNode: NodeBounds,
|
|
77
|
+
childCenter: { x: number; y: number },
|
|
78
|
+
fromColor: string,
|
|
79
|
+
toColor: string,
|
|
80
|
+
cb: TransitionCallbacks,
|
|
81
|
+
targetZoom = 1.0,
|
|
82
|
+
canvasWidth = 800,
|
|
83
|
+
canvasHeight = 600,
|
|
84
|
+
): void {
|
|
85
|
+
if (this._active) return;
|
|
86
|
+
this._active = true;
|
|
87
|
+
|
|
88
|
+
// Timing constants pulled from ANIMATION_PHASES (single source of truth)
|
|
89
|
+
const ZOOM_DURATION = ANIMATION_PHASES.ZOOM_IN.ZOOM_TO_TARGET; // 500 ms
|
|
90
|
+
const FADE_OUT_DELAY = ANIMATION_PHASES.ZOOM_IN.FADE_OUT_START; // 120 ms
|
|
91
|
+
const FADE_OUT_DURATION = ANIMATION_PHASES.ZOOM_IN.FADE_OUT; // 280 ms — completes at 400 ms
|
|
92
|
+
const SWAP_AT_MS = ANIMATION_PHASES.ZOOM_IN.CONTENT_SWAP; // 310 ms
|
|
93
|
+
const FADE_IN_DURATION = ANIMATION_PHASES.ZOOM_IN.FADE_IN; // 200 ms
|
|
94
|
+
const SETTLE_FACTOR = 0.94;
|
|
95
|
+
|
|
96
|
+
const z0 = current.zoomLevel;
|
|
97
|
+
const dx = targetNode.x - current.centerX;
|
|
98
|
+
const dy = targetNode.y - current.centerY;
|
|
99
|
+
const vmin = Math.min(canvasWidth, canvasHeight);
|
|
100
|
+
const fillZoom = Math.max(4.0, (vmin * 1.5) / Math.max(targetNode.width, 100));
|
|
101
|
+
|
|
102
|
+
const fromRgb = parseColor(fromColor);
|
|
103
|
+
const toRgb = parseColor(toColor);
|
|
104
|
+
const state = { viewport: { centerX: 0, centerY: 0, zoomLevel: 0 }, opacity: 1, backgroundColor: fromColor };
|
|
105
|
+
|
|
106
|
+
const start = performance.now();
|
|
107
|
+
let swapped = false;
|
|
108
|
+
let fadeInStart = 0;
|
|
109
|
+
|
|
110
|
+
cb.onPhaseChange?.('zoom');
|
|
111
|
+
|
|
112
|
+
const tick = (now: number): void => {
|
|
113
|
+
if (!this._active) return;
|
|
114
|
+
const elapsed = now - start;
|
|
115
|
+
|
|
116
|
+
if (!swapped) {
|
|
117
|
+
if (elapsed >= SWAP_AT_MS) {
|
|
118
|
+
swapped = true;
|
|
119
|
+
fadeInStart = now;
|
|
120
|
+
state.viewport = { centerX: childCenter.x, centerY: childCenter.y, zoomLevel: targetZoom * SETTLE_FACTOR };
|
|
121
|
+
state.opacity = 0;
|
|
122
|
+
state.backgroundColor = toColor;
|
|
123
|
+
cb.onUpdate(state);
|
|
124
|
+
cb.onPhaseChange?.('swap');
|
|
125
|
+
this.rafId = requestAnimationFrame(tick);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const rawT = Math.min(elapsed / ZOOM_DURATION, 1);
|
|
129
|
+
const zoomT = easeZoomTransition(rawT);
|
|
130
|
+
const zl = lerp(z0, fillZoom, zoomT);
|
|
131
|
+
state.viewport = {
|
|
132
|
+
centerX: targetNode.x - dx * z0 * (1 - zoomT) / zl,
|
|
133
|
+
centerY: targetNode.y - dy * z0 * (1 - zoomT) / zl,
|
|
134
|
+
zoomLevel: zl,
|
|
135
|
+
};
|
|
136
|
+
state.opacity = elapsed > FADE_OUT_DELAY
|
|
137
|
+
? Math.max(0, 1 - easeOutCubic(Math.min((elapsed - FADE_OUT_DELAY) / FADE_OUT_DURATION, 1)))
|
|
138
|
+
: 1;
|
|
139
|
+
state.backgroundColor = interpolateRgb(fromRgb, toRgb, rawT);
|
|
140
|
+
cb.onUpdate(state);
|
|
141
|
+
} else {
|
|
142
|
+
const fi = now - fadeInStart;
|
|
143
|
+
const ft = Math.min(fi / FADE_IN_DURATION, 1);
|
|
144
|
+
const eased = easeOutCubic(ft);
|
|
145
|
+
state.viewport = { centerX: childCenter.x, centerY: childCenter.y, zoomLevel: lerp(targetZoom * SETTLE_FACTOR, targetZoom, eased) };
|
|
146
|
+
state.opacity = eased;
|
|
147
|
+
state.backgroundColor = toColor;
|
|
148
|
+
cb.onUpdate(state);
|
|
149
|
+
if (ft >= 1) { cb.onPhaseChange?.('fade-in'); this.rafId = null; this._complete(cb.onComplete); return; }
|
|
150
|
+
}
|
|
151
|
+
this.rafId = requestAnimationFrame(tick);
|
|
152
|
+
};
|
|
153
|
+
this.rafId = requestAnimationFrame(tick);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Zoom out to parent layer ───────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
animateZoomOutToParent(
|
|
159
|
+
current: AnimatorViewport,
|
|
160
|
+
parent: AnimatorViewport,
|
|
161
|
+
fromColor: string,
|
|
162
|
+
toColor: string,
|
|
163
|
+
cb: TransitionCallbacks,
|
|
164
|
+
): void {
|
|
165
|
+
if (this._active) return;
|
|
166
|
+
this._active = true;
|
|
167
|
+
|
|
168
|
+
const FADE_OUT = ANIMATION_PHASES.ZOOM_OUT.FADE_OUT;
|
|
169
|
+
const FADE_IN = ANIMATION_PHASES.ZOOM_OUT.FADE_IN;
|
|
170
|
+
const OPACITY_SETTLE = 66;
|
|
171
|
+
|
|
172
|
+
const fromRgb = parseColor(fromColor);
|
|
173
|
+
const toRgb = parseColor(toColor);
|
|
174
|
+
const state = { viewport: { centerX: 0, centerY: 0, zoomLevel: 0 }, opacity: 1, backgroundColor: fromColor };
|
|
175
|
+
|
|
176
|
+
const start = performance.now();
|
|
177
|
+
let swapped = false;
|
|
178
|
+
let fadeInStart = 0;
|
|
179
|
+
|
|
180
|
+
cb.onPhaseChange?.('fade-out');
|
|
181
|
+
|
|
182
|
+
const tick = (now: number): void => {
|
|
183
|
+
if (!this._active) return;
|
|
184
|
+
const elapsed = now - start;
|
|
185
|
+
|
|
186
|
+
if (!swapped) {
|
|
187
|
+
if (elapsed >= FADE_OUT) {
|
|
188
|
+
swapped = true; fadeInStart = now;
|
|
189
|
+
state.viewport = { centerX: parent.centerX, centerY: parent.centerY, zoomLevel: parent.zoomLevel * 5 };
|
|
190
|
+
state.opacity = 0;
|
|
191
|
+
state.backgroundColor = toColor;
|
|
192
|
+
cb.onUpdate(state); cb.onPhaseChange?.('zoom-back');
|
|
193
|
+
this.rafId = requestAnimationFrame(tick); return;
|
|
194
|
+
}
|
|
195
|
+
const rawT = Math.min(elapsed / FADE_OUT, 1);
|
|
196
|
+
const t = easeOutCubic(rawT);
|
|
197
|
+
state.viewport = { centerX: current.centerX, centerY: current.centerY, zoomLevel: lerp(current.zoomLevel, current.zoomLevel * 0.6, t) };
|
|
198
|
+
state.opacity = 1 - t;
|
|
199
|
+
state.backgroundColor = interpolateRgb(fromRgb, toRgb, rawT);
|
|
200
|
+
cb.onUpdate(state);
|
|
201
|
+
} else {
|
|
202
|
+
const fi = now - fadeInStart;
|
|
203
|
+
const rawT = Math.min(fi / FADE_IN, 1);
|
|
204
|
+
const t = easeZoomTransition(rawT);
|
|
205
|
+
state.viewport = { centerX: parent.centerX, centerY: parent.centerY, zoomLevel: lerp(parent.zoomLevel * 5, parent.zoomLevel, t) };
|
|
206
|
+
state.opacity = fi < OPACITY_SETTLE ? 0 : easeZoomTransition(Math.min((fi - OPACITY_SETTLE) / (FADE_IN - OPACITY_SETTLE), 1));
|
|
207
|
+
state.backgroundColor = toColor;
|
|
208
|
+
cb.onUpdate(state);
|
|
209
|
+
if (rawT >= 1) { cb.onPhaseChange?.('fade-in'); this.rafId = null; this._complete(cb.onComplete); return; }
|
|
210
|
+
}
|
|
211
|
+
this.rafId = requestAnimationFrame(tick);
|
|
212
|
+
};
|
|
213
|
+
this.rafId = requestAnimationFrame(tick);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _complete(onComplete: () => void): void {
|
|
217
|
+
this.timers.forEach(clearTimeout); this.timers = [];
|
|
218
|
+
this._active = false;
|
|
219
|
+
onComplete();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const konvaLayerTransitionAnimator = new KonvaLayerTransitionAnimator();
|