aniview 1.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 (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/dist/Aniview.d.ts +63 -0
  5. package/dist/Aniview.d.ts.map +1 -0
  6. package/dist/Aniview.js +831 -0
  7. package/dist/Aniview.js.map +1 -0
  8. package/dist/AniviewPanel.d.ts +33 -0
  9. package/dist/AniviewPanel.d.ts.map +1 -0
  10. package/dist/AniviewPanel.js +66 -0
  11. package/dist/AniviewPanel.js.map +1 -0
  12. package/dist/GestureStressTest.d.ts +3 -0
  13. package/dist/GestureStressTest.d.ts.map +1 -0
  14. package/dist/GestureStressTest.js +125 -0
  15. package/dist/GestureStressTest.js.map +1 -0
  16. package/dist/aniviewConfig.d.ts +175 -0
  17. package/dist/aniviewConfig.d.ts.map +1 -0
  18. package/dist/aniviewConfig.js +568 -0
  19. package/dist/aniviewConfig.js.map +1 -0
  20. package/dist/aniviewProvider.d.ts +93 -0
  21. package/dist/aniviewProvider.d.ts.map +1 -0
  22. package/dist/aniviewProvider.js +229 -0
  23. package/dist/aniviewProvider.js.map +1 -0
  24. package/dist/core/AniviewLock.d.ts +16 -0
  25. package/dist/core/AniviewLock.d.ts.map +1 -0
  26. package/dist/core/AniviewLock.js +18 -0
  27. package/dist/core/AniviewLock.js.map +1 -0
  28. package/dist/core/AniviewMath.d.ts +41 -0
  29. package/dist/core/AniviewMath.d.ts.map +1 -0
  30. package/dist/core/AniviewMath.js +69 -0
  31. package/dist/core/AniviewMath.js.map +1 -0
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/useAniview.d.ts +39 -0
  37. package/dist/useAniview.d.ts.map +1 -0
  38. package/dist/useAniview.js +32 -0
  39. package/dist/useAniview.js.map +1 -0
  40. package/dist/useAniviewContext.d.ts +156 -0
  41. package/dist/useAniviewContext.d.ts.map +1 -0
  42. package/dist/useAniviewContext.js +3 -0
  43. package/dist/useAniviewContext.js.map +1 -0
  44. package/dist/useAniviewLock.d.ts +20 -0
  45. package/dist/useAniviewLock.d.ts.map +1 -0
  46. package/dist/useAniviewLock.js +32 -0
  47. package/dist/useAniviewLock.js.map +1 -0
  48. package/package.json +60 -0
  49. package/src/Aniview.tsx +868 -0
  50. package/src/AniviewPanel.tsx +141 -0
  51. package/src/GestureStressTest.tsx +144 -0
  52. package/src/__tests__/AniviewLock.test.ts +58 -0
  53. package/src/__tests__/AniviewMath.test.ts +211 -0
  54. package/src/__tests__/AniviewSnapshot.test.tsx +85 -0
  55. package/src/__tests__/__snapshots__/AniviewSnapshot.test.tsx.snap +7 -0
  56. package/src/__tests__/aniviewConfig.test.ts +70 -0
  57. package/src/aniviewConfig.tsx +688 -0
  58. package/src/aniviewProvider.tsx +307 -0
  59. package/src/core/AniviewLock.ts +23 -0
  60. package/src/core/AniviewMath.ts +107 -0
  61. package/src/index.ts +6 -0
  62. package/src/useAniview.tsx +75 -0
  63. package/src/useAniviewContext.tsx +170 -0
  64. package/src/useAniviewLock.tsx +37 -0
@@ -0,0 +1,868 @@
1
+ import React, { useState, useCallback, useMemo, useEffect } from 'react';
2
+ import { LayoutChangeEvent, StyleSheet, ViewStyle } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ interpolate,
6
+ interpolateColor,
7
+ Extrapolation,
8
+ useAnimatedReaction,
9
+ runOnJS
10
+ } from 'react-native-reanimated';
11
+ import { AniviewProps } from './useAniviewContext';
12
+ import { useAniview } from './useAniview';
13
+
14
+ /**
15
+ * HELPER: Strips mapping styles and provides transform array for reconstruction.
16
+ */
17
+ function stripLayoutProps(style: ViewStyle) {
18
+ const flattened = StyleSheet.flatten(style) || {};
19
+ const {
20
+ transform, position, left, top, right, bottom,
21
+ marginLeft, marginTop, marginRight, marginBottom,
22
+ ...rest
23
+ } = flattened;
24
+ return { rest, transform: Array.isArray(transform) ? transform : [] };
25
+ }
26
+
27
+ /**
28
+ * HELPER: Flattens transform array into O(1) property map for interpolation.
29
+ */
30
+ function flattenTransform(transform: any[]) {
31
+ const props: any = {};
32
+ transform.forEach(t => {
33
+ const key = Object.keys(t)[0];
34
+ let val = t[key];
35
+ // Handle rotation strings
36
+ if (typeof val === 'string' && val.endsWith('deg')) val = parseFloat(val);
37
+ props[`_tr_${key}`] = val;
38
+ });
39
+ return props;
40
+ }
41
+
42
+ /**
43
+ * HELPER: Normalizes any color string to RGBA for consistent interpolation.
44
+ */
45
+ function normalizeColorToRgba(c: string): string {
46
+ if (!c || c === 'transparent') return 'rgba(0,0,0,0)';
47
+ if (c.startsWith('#')) {
48
+ const hex = c.slice(1);
49
+ let r=0, g=0, b=0, a=1;
50
+ if (hex.length === 3) {
51
+ r = parseInt(hex[0]+hex[0], 16); g = parseInt(hex[1]+hex[1], 16); b = parseInt(hex[2]+hex[2], 16);
52
+ } else {
53
+ r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16);
54
+ if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16) / 255;
55
+ }
56
+ return `rgba(${r},${g},${b},${a})`;
57
+ }
58
+ const match = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
59
+ if (match) return `rgba(${match[1]},${match[2]},${match[3]},${match[4] || 1})`;
60
+
61
+ const hMatch = c.match(/hsla?\((\d+),\s*([\d.]+)%?,\s*([\d.]+)%?(?:,\s*([\d.]+))?\)/);
62
+ if (hMatch) {
63
+ const h = parseInt(hMatch[1]), s = parseFloat(hMatch[2]) / 100, l = parseFloat(hMatch[3]) / 100, a = hMatch[4] ? parseFloat(hMatch[4]) : 1;
64
+ const k = (n: number) => (n + h / 30) % 12;
65
+ const arc = s * Math.min(l, 1 - l);
66
+ const f = (n: number) => l - arc * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
67
+ return `rgba(${Math.round(255 * f(0))},${Math.round(255 * f(8))},${Math.round(255 * f(4))},${a})`;
68
+ }
69
+ return 'rgba(0,0,0,0)';
70
+ }
71
+
72
+ /**
73
+ * PURE JS COLOR INTERPOLATOR
74
+ */
75
+ function jsInterpolateColor(val: number, start: number, end: number, startColor: string, endColor: string) {
76
+ 'worklet';
77
+ if (!startColor || !endColor || startColor === endColor) return startColor || 'rgba(0,0,0,0)';
78
+ const range = (end - start) || 1;
79
+ const progress = Math.max(0, Math.min(1, (val - start) / range));
80
+
81
+ const parse = (c: string) => {
82
+ if (!c || c === 'transparent') return [0, 0, 0, 0];
83
+ if (c.startsWith('#')) {
84
+ const hex = c.slice(1);
85
+ if (hex.length === 3) return [parseInt(hex[0]+hex[0], 16), parseInt(hex[1]+hex[1], 16), parseInt(hex[2]+hex[2], 16), 1];
86
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1];
87
+ }
88
+ const match = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
89
+ if (match) return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), match[4] ? parseFloat(match[4]) : 1];
90
+
91
+ const hMatch = c.match(/hsla?\((\d+),\s*([\d.]+)%?,\s*([\d.]+)%?(?:,\s*([\d.]+))?\)/);
92
+ if (hMatch) {
93
+ const h = parseInt(hMatch[1]), s = parseFloat(hMatch[2]) / 100, l = parseFloat(hMatch[3]) / 100, a = hMatch[4] ? parseFloat(hMatch[4]) : 1;
94
+ const k = (n: number) => (n + h / 30) % 12;
95
+ const arc = s * Math.min(l, 1 - l);
96
+ const f = (n: number) => l - arc * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
97
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4)), a];
98
+ }
99
+ return [0, 0, 0, 0];
100
+ };
101
+
102
+ const s = parse(startColor), e = parse(endColor);
103
+ const r = Math.round(s[0] + (e[0] - s[0]) * progress);
104
+ const g = Math.round(s[1] + (e[1] - s[1]) * progress);
105
+ const b = Math.round(s[2] + (e[2] - s[2]) * progress);
106
+ const a = s[3] + (e[3] - s[3]) * progress;
107
+ return `rgba(${r},${g},${b},${a})`;
108
+ }
109
+
110
+ /**
111
+ * SMART INTERPOLATE COLOR
112
+ * Handles "transparent" interpolation by adopting the RGB of the non-transparent color
113
+ * to avoid darkening/graying artifacts.
114
+ */
115
+ function smartInterpolateColor(val: number, input: number[], output: string[]) {
116
+ 'worklet';
117
+ if (output.length === 0) return 'rgba(0,0,0,0)';
118
+ if (output.length === 1) return output[0];
119
+
120
+ // Bounds check
121
+ if (val <= input[0]) return output[0];
122
+ if (val >= input[input.length - 1]) return output[output.length - 1];
123
+
124
+ // Find segment
125
+ let i = 0;
126
+ // Iterate to find the correct segment
127
+ while (i < input.length - 2 && val >= input[i+1]) {
128
+ i++;
129
+ }
130
+
131
+ // Safety check
132
+ if (input[i+1] === undefined) return output[output.length-1];
133
+
134
+ const c1 = output[i];
135
+ const c2 = output[i+1];
136
+
137
+ let useC1 = c1;
138
+ let useC2 = c2;
139
+
140
+ const fix = (transp: string, source: string) => {
141
+ 'worklet';
142
+ // We target normalized 'rgba(0,0,0,0)'
143
+ if (transp !== 'rgba(0,0,0,0)') return transp;
144
+ if (!source || !source.startsWith('rgba')) return transp;
145
+
146
+ const lastComma = source.lastIndexOf(',');
147
+ if (lastComma === -1) return transp;
148
+ // Construct: source RGB + alpha 0
149
+ return source.substring(0, lastComma + 1) + '0)';
150
+ };
151
+
152
+ useC1 = fix(c1, c2);
153
+ useC2 = fix(c2, c1);
154
+
155
+ return interpolateColor(val, [input[i], input[i+1]], [useC1, useC2]);
156
+ }
157
+
158
+ /**
159
+ * OPTIMIZATION: One-time Segment Data Search
160
+ * Finds the segment index and progress percentage once per frame so we
161
+ * don't have to perform a search for every single style property.
162
+ */
163
+ function getSegmentInfo(val: number, input: number[]) {
164
+ 'worklet';
165
+ const len = input.length;
166
+ if (len === 0) return { i: 0, p: 0, constant: true };
167
+ if (len === 1 || val <= input[0]) return { i: 0, p: 0, constant: true };
168
+ if (val >= input[len - 1]) return { i: len - 1, p: 0, constant: true };
169
+
170
+ let i = 0;
171
+ while (i < len - 1 && val >= input[i + 1]) {
172
+ i++;
173
+ }
174
+ const gap = input[i + 1] - input[i];
175
+ const p = gap > 0.001 ? (val - input[i]) / gap : 0;
176
+ return { i, p, constant: false };
177
+ }
178
+
179
+ interface BakedLane {
180
+ values: number[];
181
+ outputRanges: any[][];
182
+ keys: string[];
183
+ persistent: boolean;
184
+ }
185
+
186
+ interface BakedResult {
187
+ homeX: number;
188
+ homeY: number;
189
+ localX: number;
190
+ localY: number;
191
+ bakedH: any[];
192
+ bakedV: any[];
193
+ bakedCH: any[];
194
+ bakedCV: any[];
195
+ eventLanes: Record<string, BakedLane>;
196
+ uniqueX: number[];
197
+ uniqueY: number[];
198
+ numericKeys: string[];
199
+ colorKeys: string[];
200
+ baseProps: any;
201
+ homeProps: any;
202
+ }
203
+
204
+ /**
205
+ * **Aniview** — Absolute World Coordinate Animation Engine Component
206
+ *
207
+ * The core animated view that interpolates its style properties based on
208
+ * the camera's position in a virtual 2D world coordinate system. Each
209
+ * `Aniview` belongs to a specific `pageId` (its "home page") and defines
210
+ * `frames` — keyframes that describe how the component should look when
211
+ * the camera is at a different page or when a custom event reaches a
212
+ * certain value.
213
+ *
214
+ * ### How it works (the "Baking" pipeline)
215
+ *
216
+ * 1. On mount (or when `frames`/`style`/`dimensions` change), Aniview
217
+ * runs a one-time **bake** on the JS thread. This pre-computes a
218
+ * lookup grid of style values indexed by world coordinates.
219
+ * 2. On every frame, `useAnimatedStyle` reads the camera SharedValues
220
+ * (`events.x`, `events.y`) and performs O(1) segment lookups +
221
+ * linear/color interpolation — no searching, no string parsing.
222
+ * 3. The baked data also powers **native virtualization**: components
223
+ * farther than 1.5× screen width from the camera are hidden via
224
+ * `opacity: 0`, keeping the render tree lightweight.
225
+ *
226
+ * ### Key design choices
227
+ *
228
+ * - **Absolute positioning**: All Aniview children are rendered with
229
+ * `position: 'absolute'` and placed via `translateX`/`translateY`
230
+ * relative to the camera. This decouples layout from animation.
231
+ * - **Smart color interpolation**: When transitioning to/from
232
+ * `transparent`, the RGB of the non-transparent color is preserved
233
+ * to avoid grey flash artifacts.
234
+ * - **Selective unmounting**: Non-persistent components unmount when
235
+ * offscreen (after snapping completes) to reclaim memory. Set
236
+ * `persistent={true}` for 3D/GL content that must stay mounted.
237
+ *
238
+ * @param props Standard `ViewProps` plus:
239
+ * @param props.pageId - The page this component belongs to (numeric index or semantic name from `pageMap`)
240
+ * @param props.frames - Keyframe definitions. Object keys are frame names, values are {@link AniviewFrame}
241
+ * @param props.persistent - If `true`, stays mounted even when far offscreen. Required for WebGL/Three.js canvases. Default: `false`
242
+ * @param props.style - Base styles applied when at home position. Numeric and color props here become the interpolation baseline.
243
+ * @param props.pointerEvents - Standard RN pointer events. Useful for overlay pages that shouldn't block touches.
244
+ *
245
+ * @example
246
+ * ```tsx
247
+ * <Aniview
248
+ * pageId="HOME"
249
+ * style={{ width: '100%', height: '100%', backgroundColor: '#fff' }}
250
+ * frames={{
251
+ * away: { page: 'SETTINGS', opacity: 0 },
252
+ * scrolled: { event: 'scrollY', value: 100, style: { transform: [{ translateY: -50 }] } },
253
+ * }}
254
+ * >
255
+ * <HomeContent />
256
+ * </Aniview>
257
+ * ```
258
+ *
259
+ * @see {@link AniviewProvider} for setting up the coordinate system
260
+ * @see {@link AniviewConfig} for layout and gesture configuration
261
+ * @see {@link useAniview} for accessing context from child components
262
+ */
263
+ export default function Aniview(props: AniviewProps) {
264
+ const { style, children, pageId, frames, ...rest } = props;
265
+
266
+ const context = useAniview(props);
267
+ const { events, config, dimensions, isMoving } = context;
268
+
269
+ const persistent = props.persistent ?? false;
270
+ const [shouldRender, setShouldRender] = useState(true);
271
+
272
+ // Pre-calculate target X on the JS thread to avoid UI thread crash (calling class methods)
273
+ const homeX = useMemo(() => {
274
+ if (!config || !dimensions.width) return 0;
275
+ const homeId = config.resolvePageId(pageId);
276
+ return config.getPageOffset(homeId, dimensions).x;
277
+ }, [config, pageId, dimensions.width, dimensions.height]);
278
+
279
+ // SELECTIVE UNMOUNTING: If not persistent, monitor visibility natively.
280
+ useAnimatedReaction(
281
+ () => {
282
+ if (persistent || !isMoving || isMoving.value) return null;
283
+ const cameraX = events.x.value;
284
+ const isFar = Math.abs(cameraX - homeX) > dimensions.width * 1.5;
285
+ return isFar;
286
+ },
287
+ (isFar) => {
288
+ if (isFar === null) return null;
289
+ if (isFar && shouldRender) runOnJS(setShouldRender)(false);
290
+ if (!isFar && !shouldRender) runOnJS(setShouldRender)(true);
291
+ },
292
+ [persistent, dimensions.width, homeX, shouldRender, isMoving]
293
+ );
294
+
295
+ const hasNoDims = dimensions.width <= 0 || dimensions.height <= 0;
296
+ const cached = useMemo(() => (config as any)?.getLayout(pageId.toString()), [config, pageId]);
297
+ const [localLayout, setLocalLayout] = useState<{ x: number; y: number } | null>(cached || null);
298
+
299
+ useEffect(() => {
300
+ if (localLayout && !cached) {
301
+ (config as any)?.registerLayout(pageId.toString(), localLayout);
302
+ }
303
+ }, [localLayout, cached, config, pageId]);
304
+
305
+ // FIX: Sync localLayout with controlled style changes
306
+ useEffect(() => {
307
+ const flat = StyleSheet.flatten(style || {}) as ViewStyle;
308
+ const x = (flat.left as number) ?? (flat.marginLeft as number) ?? 0;
309
+ const y = (flat.top as number) ?? (flat.marginTop as number) ?? 0;
310
+
311
+ if (!localLayout || Math.abs(localLayout.x - x) > 0.1 || Math.abs(localLayout.y - y) > 0.1) {
312
+ setLocalLayout({ x, y });
313
+ }
314
+ }, [style, localLayout]);
315
+
316
+ const onLayout = useCallback((e: any) => {
317
+ let { x, y, width, height } = e.nativeEvent.layout;
318
+ const flattened = StyleSheet.flatten(props.style || {}) as ViewStyle;
319
+ if (x === 0 && typeof flattened?.marginLeft === 'number') x = flattened.marginLeft;
320
+ if (y === 0 && typeof flattened?.marginTop === 'number') y = flattened.marginTop;
321
+
322
+ if (!localLayout || Math.abs(localLayout.x - x) > 1 || Math.abs(localLayout.y - y) > 1) {
323
+ if (width > 0 || height > 0) {
324
+ setLocalLayout({ x, y });
325
+ }
326
+ }
327
+ }, [localLayout, props.style]);
328
+
329
+
330
+ const baseStyle = useMemo(() => {
331
+ const flattened = StyleSheet.flatten(style as any) || {};
332
+ return { ...flattened, position: 'absolute' } as ViewStyle;
333
+ }, [style]);
334
+
335
+ // --- THE BAKE ---
336
+ const baked = useMemo((): BakedResult | null => {
337
+ if (!localLayout || !config) return null;
338
+ const isColorProp = (k: string) => k.toLowerCase().includes('color') || k === 'tintColor';
339
+
340
+ try {
341
+ const bakeInfo = config.register(pageId, dimensions, frames, localLayout);
342
+ const { homeOffset } = bakeInfo;
343
+ const homeX = homeOffset.x + localLayout.x;
344
+ const homeY = homeOffset.y + localLayout.y;
345
+ const baseOpacity = (baseStyle as any).opacity ?? 1;
346
+
347
+ // 1. Initial State Extraction
348
+ const homeProps: any = { worldX: homeX, worldY: homeY, opacity: baseOpacity };
349
+ const flatBase = StyleSheet.flatten(style as any) || {};
350
+
351
+ for (const k in flatBase) {
352
+ const v = (flatBase as any)[k];
353
+ if (typeof v === 'number' || (typeof v === 'string' && isColorProp(k))) {
354
+ homeProps[k] = (typeof v === 'string' && isColorProp(k)) ? normalizeColorToRgba(v) : v;
355
+ } else if (k === 'transform' && Array.isArray(v)) {
356
+ Object.assign(homeProps, flattenTransform(v));
357
+ } else if (k === 'shadowOffset' || k === 'textShadowOffset') {
358
+ homeProps[k + 'Width'] = v?.width ?? 0;
359
+ homeProps[k + 'Height'] = v?.height ?? 0;
360
+ }
361
+ }
362
+
363
+ // 2. Pre-scan all frames to find ALL active keys and update homeProps
364
+ if (frames) {
365
+ const registration = bakeInfo;
366
+ const scanFrame = (frame: any) => {
367
+ if (frame.style) {
368
+ const fStyles = Array.isArray(frame.style) ? frame.style : [frame.style];
369
+ fStyles.forEach((s: any) => {
370
+ const fFlat = StyleSheet.flatten(s) || {};
371
+ for (const k in fFlat) {
372
+ const v = (fFlat as any)[k];
373
+ let key = k;
374
+ if (k === 'transform' && Array.isArray(v)) {
375
+ const flatTr = flattenTransform(v);
376
+ Object.keys(flatTr).forEach(tk => {
377
+ if (homeProps[tk] === undefined) {
378
+ homeProps[tk] = tk.includes('scale') ? 1 : 0;
379
+ }
380
+ });
381
+ } else if (k === 'shadowOffset' || k === 'textShadowOffset') {
382
+ if (homeProps[k + 'Width'] === undefined) {
383
+ homeProps[k + 'Width'] = 0;
384
+ homeProps[k + 'Height'] = 0;
385
+ }
386
+ } else if (homeProps[k] === undefined && (typeof v === 'number' || (typeof v === 'string' && isColorProp(k)))) {
387
+ homeProps[k] = (k === 'opacity' || k.includes('scale')) ? 1 : (isColorProp(k) ? 'rgba(0,0,0,0)' : 0);
388
+ }
389
+ }
390
+ });
391
+ }
392
+ if (frame.opacity !== undefined && homeProps.opacity === undefined) homeProps.opacity = 1;
393
+ if (frame.scale !== undefined && homeProps._tr_scale === undefined) homeProps._tr_scale = 1;
394
+ if (frame.rotate !== undefined && homeProps._tr_rotate === undefined) homeProps._tr_rotate = 0;
395
+ };
396
+
397
+ for (const frameKey in registration.bakedFrames) {
398
+ scanFrame(registration.bakedFrames[frameKey]);
399
+ }
400
+
401
+ for (const laneKey in registration.eventLanes) {
402
+ registration.eventLanes[laneKey].forEach(scanFrame);
403
+ }
404
+ }
405
+
406
+ // 3. Spatial Grid Construction (2D)
407
+ const grid: Record<string, any> = { [`${homeOffset.x}_${homeOffset.y}`]: homeProps };
408
+ const uniqueX = new Set<number>([homeOffset.x]);
409
+ const uniqueY = new Set<number>([homeOffset.y]);
410
+ const activeKeys = new Set<string>();
411
+
412
+ // 4. Event Lane Construction (1D)
413
+ const eventLanes: Record<string, BakedLane> = {};
414
+
415
+ if (frames) {
416
+ const registration = bakeInfo;
417
+
418
+ // Process Spatial Frames
419
+ for (const frameKey in registration.bakedFrames) {
420
+ const frame = registration.bakedFrames[frameKey];
421
+ const targetX = frame.worldX + homeOffset.x;
422
+ const targetY = frame.worldY + homeOffset.y;
423
+ uniqueX.add(targetX); uniqueY.add(targetY);
424
+
425
+ // Start with a full copy of COMPLETE homeProps
426
+ const frameProps: any = { ...homeProps, worldX: targetX + localLayout.x, worldY: targetY + localLayout.y };
427
+ if (frame.style) {
428
+ const fStyles = Array.isArray(frame.style) ? frame.style : [frame.style];
429
+ fStyles.forEach(s => {
430
+ const fFlat = StyleSheet.flatten(s) || {};
431
+ for (const k in fFlat) {
432
+ const v = (fFlat as any)[k];
433
+ if (k === 'transform' && Array.isArray(v)) {
434
+ Object.assign(frameProps, flattenTransform(v));
435
+ } else if (k === 'shadowOffset' || k === 'textShadowOffset') {
436
+ frameProps[k + 'Width'] = v?.width ?? 0;
437
+ frameProps[k + 'Height'] = v?.height ?? 0;
438
+ } else if (typeof v === 'number' || (typeof v === 'string' && isColorProp(k))) {
439
+ let finalV = v;
440
+ if (typeof v === 'string' && isColorProp(k)) finalV = normalizeColorToRgba(v);
441
+
442
+ if (k === 'left' || k === 'marginLeft') frameProps.worldX = targetX + Number(v) + (dimensions.offsetX || 0);
443
+ else if (k === 'top' || k === 'marginTop') frameProps.worldY = targetY + Number(v) + (dimensions.offsetY || 0);
444
+ else frameProps[k] = finalV;
445
+
446
+ // If this is a NEW key not in homeProps, add it to homeProps with a default
447
+ if (homeProps[k] === undefined) {
448
+ homeProps[k] = (k === 'opacity' || k.includes('scale')) ? 1 : (isColorProp(k) ? 'rgba(0,0,0,0)' : 0);
449
+ }
450
+ }
451
+ }
452
+ });
453
+ }
454
+ // Add shortcuts
455
+ if (frame.opacity !== undefined) frameProps.opacity = frame.opacity;
456
+ if (frame.scale !== undefined) frameProps._tr_scale = frame.scale;
457
+ if (frame.rotate !== undefined) frameProps._tr_rotate = frame.rotate;
458
+
459
+ for (const p in frameProps) {
460
+ if (frameProps[p] !== homeProps[p]) activeKeys.add(p);
461
+ }
462
+ grid[`${targetX}_${targetY}`] = frameProps;
463
+ }
464
+
465
+ // Process Event Lanes
466
+ for (const eventName in registration.eventLanes) {
467
+ const framesInLane = registration.eventLanes[eventName];
468
+ const inputValues = framesInLane.map(f => f.value || 0);
469
+ const keysInLane = new Set<string>();
470
+
471
+ framesInLane.forEach(f => {
472
+ const fStyles = Array.isArray(f.style) ? f.style : (f.style ? [f.style] : []);
473
+ fStyles.forEach(s => {
474
+ const flat = StyleSheet.flatten(s) || {};
475
+ Object.keys(flat).forEach(k => {
476
+ if (k === 'transform') {
477
+ const tr = flattenTransform((flat as any).transform);
478
+ Object.keys(tr).forEach(tk => keysInLane.add(tk));
479
+ } else {
480
+ keysInLane.add(k);
481
+ }
482
+ });
483
+ });
484
+ if (f.opacity !== undefined) keysInLane.add('opacity');
485
+ if (f.scale !== undefined) keysInLane.add('_tr_scale');
486
+ if (f.rotate !== undefined) keysInLane.add('_tr_rotate');
487
+ });
488
+
489
+ const keysArr = Array.from(keysInLane);
490
+ const outputRanges: any[][] = keysArr.map(k => {
491
+ return framesInLane.map(f => {
492
+ const fStyles = Array.isArray(f.style) ? f.style : (f.style ? [f.style] : []);
493
+ let val = homeProps[k];
494
+ fStyles.forEach(s => {
495
+ const flat = StyleSheet.flatten(s) || {};
496
+ if (k.startsWith('_tr_')) {
497
+ const tr = flattenTransform((flat as any).transform || []);
498
+ if (tr[k] !== undefined) val = tr[k];
499
+ } else {
500
+ if ((flat as any)[k] !== undefined) val = (flat as any)[k];
501
+ }
502
+ });
503
+ if (k === 'opacity' && f.opacity !== undefined) val = f.opacity;
504
+ if (k === '_tr_scale' && f.scale !== undefined) val = f.scale;
505
+ if (k === '_tr_rotate' && f.rotate !== undefined) val = f.rotate;
506
+ return val;
507
+ });
508
+ });
509
+
510
+ const isPersistent = framesInLane.some(f => f.persistent === true);
511
+ eventLanes[eventName] = { values: inputValues, outputRanges, keys: keysArr, persistent: isPersistent };
512
+ keysArr.forEach(k => activeKeys.add(k));
513
+ }
514
+ }
515
+
516
+ const sortedX = Array.from(uniqueX).sort((a, b) => a - b);
517
+ const sortedY = Array.from(uniqueY).sort((a, b) => a - b);
518
+
519
+ const numericKeys: string[] = ['worldX', 'worldY', 'opacity'];
520
+ const colorKeys: string[] = [];
521
+
522
+ Array.from(activeKeys).forEach(k => {
523
+ if (k === 'worldX' || k === 'worldY' || k === 'opacity') return;
524
+ if (isColorProp(k)) colorKeys.push(k); else numericKeys.push(k);
525
+ });
526
+
527
+ const finalize = (tAxis: number[], fAxis: number[], isH: boolean, keys: string[], isColor: boolean) => {
528
+ return fAxis.map(fVal => {
529
+ const values: any[][] = [];
530
+ const constFlags: boolean[] = [];
531
+ keys.forEach(k => {
532
+ const outputs: any[] = [];
533
+ let isConst = true;
534
+ const anchors = tAxis.filter(t => grid[isH ? `${t}_${fVal}` : `${fVal}_${t}`]).map(t => ({ t, v: grid[isH ? `${t}_${fVal}` : `${fVal}_${t}`][k] ?? homeProps[k] }));
535
+ tAxis.forEach((t, i) => {
536
+ let calc = homeProps[k];
537
+ if (anchors.length > 0) {
538
+ let l = anchors[0], r = anchors[anchors.length-1];
539
+ for (let j=0; j<anchors.length-1; j++) if (t >= anchors[j].t && t <= anchors[j+1].t) { l=anchors[j]; r=anchors[j+1]; break; }
540
+ if (t <= l.t) calc = l.v; else if (t >= r.t) calc = r.v;
541
+ else {
542
+ if (isColor) calc = jsInterpolateColor(t, l.t, r.t, l.v, r.v);
543
+ else calc = l.v + ((t - l.t) / ((r.t - l.t) || 1)) * (r.v - l.v);
544
+ }
545
+ }
546
+ outputs.push(calc);
547
+ if (i > 0 && calc !== outputs[0]) isConst = false;
548
+ });
549
+ values.push(outputs); constFlags.push(isConst);
550
+ });
551
+ return { fixed: fVal, values, constFlags };
552
+ });
553
+ };
554
+
555
+ if (pageId === 'ROOM' && frames) {
556
+ // Keep minimal or no logging here
557
+ }
558
+
559
+ return {
560
+ homeX: homeOffset.x, homeY: homeOffset.y, localX: localLayout.x, localY: localLayout.y,
561
+ bakedH: finalize(sortedX, sortedY, true, numericKeys, false),
562
+ bakedV: finalize(sortedY, sortedX, false, numericKeys, false),
563
+ bakedCH: finalize(sortedX, sortedY, true, colorKeys, true),
564
+ bakedCV: finalize(sortedY, sortedX, false, colorKeys, true),
565
+ eventLanes,
566
+ uniqueX: sortedX, uniqueY: sortedY,
567
+ numericKeys, colorKeys,
568
+ baseProps: stripLayoutProps(baseStyle),
569
+ homeProps
570
+ };
571
+ } catch (e) { console.error('[Aniview] Bake Failed', e); return null; }
572
+ }, [localLayout, config, pageId, frames, style, baseStyle, dimensions.offsetX, dimensions.offsetY, dimensions.width, dimensions.height]);
573
+
574
+ const cameraX_SV = (events as any)?.x;
575
+ const cameraY_SV = (events as any)?.y;
576
+
577
+ const isSingleRowVal = (config as any).layout?.length === 1;
578
+
579
+ const animatedStyle = useAnimatedStyle(() => {
580
+ if (!baked || !cameraX_SV || !cameraY_SV || !config) return { opacity: 0 };
581
+ const cameraX = cameraX_SV.value, cameraY = cameraY_SV.value;
582
+
583
+ // NATIVE VIRTUALIZATION CHECK (Spatial)
584
+ // We hide components that are more than 1.5 screen-widths away from the camera.
585
+ // This is 100% UI-thread safe and replaces the problematic JS Set check.
586
+ const thresholdX = dimensions.width * 1.5;
587
+ const thresholdY = dimensions.height * 1.5;
588
+
589
+ const distX = Math.abs(cameraX - baked.homeX);
590
+ const distY = Math.abs(cameraY - baked.homeY);
591
+
592
+ let isVisibleX = distX <= thresholdX;
593
+ if (!isVisibleX) {
594
+ for (let i = 0; i < baked.uniqueX.length; i++) {
595
+ if (Math.abs(cameraX - baked.uniqueX[i]) < thresholdX) {
596
+ isVisibleX = true;
597
+ break;
598
+ }
599
+ }
600
+ }
601
+
602
+ let isVisibleY = distY <= thresholdY;
603
+ if (!isVisibleY) {
604
+ for (let i = 0; i < baked.uniqueY.length; i++) {
605
+ if (Math.abs(cameraY - baked.uniqueY[i]) < thresholdY) {
606
+ isVisibleY = true;
607
+ break;
608
+ }
609
+ }
610
+ }
611
+
612
+ if (!isVisibleX || !isVisibleY) return { opacity: 0 };
613
+
614
+ // Safety: If dimensions are 0 (startup), skip calculation
615
+ if (dimensions.width <= 0 || dimensions.height <= 0 || isNaN(dimensions.width) || isNaN(dimensions.height)) {
616
+ return { opacity: 0 };
617
+ }
618
+
619
+ const windowX = dimensions.width * 0.4;
620
+ const windowY = dimensions.height * 0.4;
621
+
622
+ let pX = 1 - Math.min(1, Math.max(0, Math.abs(cameraX - baked.homeX) / windowX));
623
+ let pY = isSingleRowVal ? 1 : (1 - Math.min(1, Math.max(0, Math.abs(cameraY - baked.homeY) / windowY)));
624
+
625
+ // Sharpen the curve so events are exclusive and don't 'leak' visual offsets.
626
+ if (!pX || isNaN(pX)) pX = 0;
627
+ if (!pY || isNaN(pY)) pY = 0;
628
+
629
+ // Softened power-2 curve for smoother transitions and better responsiveness
630
+ const presenceX = Math.pow(pX, 2);
631
+ const presenceY = isSingleRowVal ? 1 : Math.pow(pY, 2);
632
+ const presence = presenceX * presenceY;
633
+
634
+ const getLaneInfo = (lanes: any[], pos: number) => {
635
+ if (!lanes || lanes.length === 0) return null;
636
+ let i = 0; while (i < lanes.length - 1 && pos > lanes[i].fixed) i++;
637
+ const l1idx = Math.max(0, i-1), l2idx = i;
638
+ const l1 = lanes[l1idx], l2 = lanes[l2idx];
639
+ if (!l1 || !l2) return null;
640
+ const gap = l2.fixed - l1.fixed;
641
+ const mix = Math.max(0, Math.min(1, gap > 0.1 ? (pos - l1.fixed) / gap : 0));
642
+ return { l1: { ...l1, idx: l1idx }, l2: { ...l2, idx: l2idx }, mix, dist: Math.min(Math.abs(pos-l1.fixed), Math.abs(pos-l2.fixed)) };
643
+ };
644
+
645
+ const laneH = getLaneInfo(baked.bakedH, cameraY), laneV = getLaneInfo(baked.bakedV, cameraX);
646
+ if (!laneH || !laneV) return { opacity: 0 };
647
+
648
+ const viewportSize = dimensions.width;
649
+ const isNearH = laneH.dist < viewportSize, isNearV = laneV.dist < viewportSize;
650
+ if (!isNearH && !isNearV) return { opacity: 0 };
651
+
652
+ const props: any = {
653
+ ...baked.baseProps.rest,
654
+ position: 'absolute',
655
+ width: baked.baseProps.rest.width ?? dimensions.width,
656
+ height: baked.baseProps.rest.height ?? dimensions.height,
657
+ };
658
+ // If user provided left/top in style, we should probably ignore them in the final composite
659
+ // because they are already in localLayout and baked.localX/Y.
660
+ props.left = 0;
661
+ props.top = 0;
662
+ const hWeight = isNearV ? (isNearH ? laneV.dist / (laneH.dist + laneV.dist + 0.001) : 0) : 1;
663
+
664
+ // ONE-TIME OPTIMIZATION: Search for segment indices ONCE per frame
665
+ const segX = getSegmentInfo(cameraX, baked.uniqueX);
666
+ const segY = getSegmentInfo(cameraY, baked.uniqueY);
667
+
668
+ let finalX = 0, finalY = 0, finalOp = 1;
669
+
670
+ const spatialVals: Record<string, any> = { ...baked.homeProps };
671
+
672
+ const runSpatial = (keys: string[], baked_H: any[], baked_V: any[], isColor: boolean) => {
673
+ const laneH_L = baked_H[laneH.l1.idx || 0], laneH_R = baked_H[laneH.l2.idx || 0];
674
+ const laneV_L = baked_V[laneV.l1.idx || 0], laneV_R = baked_V[laneV.l2.idx || 0];
675
+
676
+ keys.forEach((k, i) => {
677
+ let vH: any = baked.homeProps[k], vV: any = baked.homeProps[k];
678
+ if (isNearH && laneH_L && laneH_R) {
679
+ const o1 = laneH_L.values[i], o2 = laneH_R.values[i];
680
+ if (o1 && o2) {
681
+ const r1 = (laneH_L.constFlags[i] || segX.constant) ? o1[segX.i] : (isColor ? smartInterpolateColor(cameraX, baked.uniqueX, o1) : o1[segX.i] + segX.p * (o1[segX.i+1] - o1[segX.i]));
682
+ const r2 = (laneH_R.constFlags[i] || segX.constant) ? o2[segX.i] : (isColor ? smartInterpolateColor(cameraX, baked.uniqueX, o2) : o2[segX.i] + segX.p * (o2[segX.i+1] - o2[segX.i]));
683
+ vH = isColor ? smartInterpolateColor(laneH.mix, [0, 1], [r1, r2]) : (r1 as number) + laneH.mix * ((r2 as number) - (r1 as number));
684
+ }
685
+ }
686
+ if (isNearV && laneV_L && laneV_R) {
687
+ const o1 = laneV_L.values[i], o2 = laneV_R.values[i];
688
+ if (o1 && o2) {
689
+ const r1 = (laneV_L.constFlags[i] || segY.constant) ? o1[segY.i] : (isColor ? smartInterpolateColor(cameraY, baked.uniqueY, o1) : o1[segY.i] + segY.p * (o1[segY.i+1] - o1[segY.i]));
690
+ const r2 = (laneV_R.constFlags[i] || segY.constant) ? o2[segY.i] : (isColor ? smartInterpolateColor(cameraY, baked.uniqueY, o2) : o2[segY.i] + segY.p * (o2[segY.i+1] - o2[segY.i]));
691
+ vV = isColor ? smartInterpolateColor(laneV.mix, [0, 1], [r1, r2]) : (r1 as number) + laneV.mix * ((r2 as number) - (r1 as number));
692
+ }
693
+ }
694
+ if (!isColor) {
695
+ if (typeof vH !== 'number' || isNaN(vH)) vH = baked.homeProps[k] || 0;
696
+ if (typeof vV !== 'number' || isNaN(vV)) vV = baked.homeProps[k] || 0;
697
+ }
698
+ spatialVals[k] = (isNearH && isNearV) ? (isColor ? smartInterpolateColor(hWeight, [0, 1], [vV, vH]) : vH * hWeight + vV * (1-hWeight)) : (isNearH ? vH : vV);
699
+ });
700
+ };
701
+
702
+ runSpatial(baked.numericKeys, baked.bakedH, baked.bakedV, false);
703
+ if (baked.colorKeys.length > 0) runSpatial(baked.colorKeys, baked.bakedCH, baked.bakedCV, true);
704
+
705
+ // 2. Event Phase
706
+ const eventResults: Record<string, any> = {};
707
+ const eventColorResults: Record<string, string> = {};
708
+
709
+ (Object.entries(baked.eventLanes) as [string, BakedLane][]).forEach(([eName, lane]) => {
710
+ const driver = (events as any)[eName];
711
+ if (!driver) return;
712
+ const val = driver.value;
713
+ const laneLen = lane.values.length;
714
+ const laneWeight = lane.persistent ? 1 : presence; // presence is ~1 on home page
715
+
716
+ // Debug Log
717
+ if (eName === 'pullDown') {
718
+ // console.log(`[Aniview Worklet] pullDown val: ${val} (Presence: ${presence.toFixed(2)})`);
719
+ }
720
+
721
+ lane.keys.forEach((k: string, i: number) => {
722
+ const isColor = baked.colorKeys.includes(k);
723
+ if (isColor) {
724
+ const range = lane.outputRanges[i] as string[];
725
+ let result: string;
726
+ // Safety: interpolateColor needs at least 2 points
727
+ if (laneLen < 2) {
728
+ result = range[0];
729
+ } else {
730
+ result = interpolateColor(val, lane.values, range) as string;
731
+ }
732
+
733
+ // Modulate color towards homeProps[k]
734
+ if (laneWeight < 0.99) {
735
+ eventColorResults[k] = smartInterpolateColor(laneWeight, [0, 1], [baked.homeProps[k] as string, result]);
736
+ } else {
737
+ eventColorResults[k] = result;
738
+ }
739
+ } else {
740
+ const range = lane.outputRanges[i] as number[];
741
+ let result = baked.homeProps[k] ?? 0;
742
+
743
+ if (laneLen === 1) {
744
+ const v1 = lane.values[0];
745
+ if (Math.abs(v1) > 0.001) {
746
+ result = interpolate(val, [0, v1], [baked.homeProps[k] ?? 0, range[0]], Extrapolation.CLAMP);
747
+ } else {
748
+ result = val > 0 ? range[0] : (baked.homeProps[k] ?? 0);
749
+ }
750
+ } else if (laneLen >= 2) {
751
+ let hasDuplicate = false;
752
+ for(let j=0; j<laneLen-1; j++) if(Math.abs(lane.values[j+1] - lane.values[j]) < 0.0001) hasDuplicate = true;
753
+ if (!hasDuplicate) {
754
+ result = interpolate(val, lane.values, range, Extrapolation.CLAMP);
755
+ } else {
756
+ result = range[0];
757
+ }
758
+ }
759
+
760
+ const diff = (result - (baked.homeProps[k] ?? 0)) * laneWeight;
761
+ if (!eventResults[k]) eventResults[k] = (baked.homeProps[k] ?? 0) + diff;
762
+ else eventResults[k] += diff;
763
+ }
764
+ });
765
+ });
766
+
767
+ // 3. Composition Phase
768
+ const compositeNumeric = (k: string, base: number) => {
769
+ const evVal = eventResults[k];
770
+ if (evVal === undefined) return base ?? 0;
771
+ const homeVal = baked.homeProps[k] ?? 0;
772
+ const evDiff = evVal - homeVal;
773
+
774
+ if (k === 'opacity' || k === '_tr_scale') {
775
+ return (base ?? 0) * (evVal / (homeVal || 1));
776
+ }
777
+ return (base ?? 0) + evDiff;
778
+ };
779
+
780
+ finalX = compositeNumeric('worldX', spatialVals.worldX ?? 0);
781
+ finalY = compositeNumeric('worldY', spatialVals.worldY ?? 0);
782
+ finalOp = Math.max(0, Math.min(1, compositeNumeric('opacity', spatialVals.opacity ?? 1)));
783
+
784
+ const layoutXKeys = ['left', 'marginLeft'];
785
+ const layoutYKeys = ['top', 'marginTop'];
786
+
787
+ // Build final props
788
+ const allKeys = new Set([...baked.numericKeys, ...baked.colorKeys]);
789
+ allKeys.forEach(k => {
790
+ if (k === 'worldX' || k === 'worldY' || k === 'opacity') return;
791
+ const spatialVal = spatialVals[k];
792
+ const isColor = baked.colorKeys.includes(k);
793
+
794
+ let res: any;
795
+ if (isColor) {
796
+ res = eventColorResults[k] ?? spatialVal ?? baked.homeProps[k];
797
+ } else {
798
+ res = compositeNumeric(k, spatialVal);
799
+ }
800
+
801
+ if (!isColor && isNaN(res as any)) res = baked.homeProps[k];
802
+
803
+ // Redirect layout props to final transform to avoid double-dipping results.
804
+ // This ensures that animating 'marginTop' actually moves the component via translateY.
805
+ if (!isColor) {
806
+ if (layoutXKeys.includes(k)) {
807
+ finalX += (res - (baked.homeProps[k] ?? 0));
808
+ return;
809
+ }
810
+ if (layoutYKeys.includes(k)) {
811
+ finalY += (res - (baked.homeProps[k] ?? 0));
812
+ return;
813
+ }
814
+ }
815
+
816
+ if (k.startsWith('_tr_')) {
817
+ if (!props.transform) props.transform = [];
818
+ const tName = k.slice(4);
819
+ let tVal = res; if (tName === 'rotate' || tName === 'rotateX' || tName === 'rotateY' || tName === 'rotateZ') tVal = `${res}deg`;
820
+ props.transform.push({ [tName]: tVal });
821
+ } else if (
822
+ k === 'shadowOffsetWidth' ||
823
+ k === 'shadowOffsetHeight' ||
824
+ k === 'textShadowOffsetWidth' ||
825
+ k === 'textShadowOffsetHeight'
826
+ ) {
827
+ const b = k.startsWith('shadowOffset') ? 'shadowOffset' : 'textShadowOffset';
828
+ props[b] = { ...props[b], [k.endsWith('Width') ? 'width' : 'height']: res };
829
+ } else props[k] = res;
830
+ });
831
+
832
+ props.opacity = finalOp;
833
+ const sx = cameraX || 0, sy = cameraY || 0;
834
+ const tx = (finalX - sx), ty = (finalY - sy);
835
+ const baseTransform = baked.baseProps.transform || [];
836
+ props.transform = [{ translateX: isNaN(tx) ? 0 : tx }, { translateY: isNaN(ty) ? 0 : ty }, ...(props.transform || []), ...baseTransform];
837
+
838
+ return props;
839
+ }, [baked, dimensions.width, dimensions.height, cameraX_SV, cameraY_SV, events]);
840
+
841
+ // Final safety: If no layout yet, show placeholder at opacity 0
842
+ if (!localLayout || hasNoDims) {
843
+ return (
844
+ <Animated.View
845
+ onLayout={onLayout}
846
+ style={[
847
+ baseStyle,
848
+ {
849
+ opacity: 0,
850
+ width: baseStyle.width ?? dimensions.width,
851
+ height: baseStyle.height ?? dimensions.height
852
+ }
853
+ ]}
854
+ pointerEvents="none"
855
+ >
856
+ {children}
857
+ </Animated.View>
858
+ );
859
+ }
860
+
861
+ if (!shouldRender && !persistent) return null;
862
+
863
+ return (
864
+ <Animated.View style={animatedStyle as any} {...rest}>
865
+ {children}
866
+ </Animated.View>
867
+ );
868
+ }