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,688 @@
1
+ import { AniviewContextType, AniviewFrame, BakedFrame, IAniviewConfig } from "./useAniviewContext";
2
+ import { Gesture } from "react-native-gesture-handler";
3
+ import {
4
+ SharedValue,
5
+ withSpring,
6
+ cancelAnimation,
7
+ makeMutable,
8
+ runOnJS
9
+ } from "react-native-reanimated";
10
+ import * as AniviewMath from './core/AniviewMath';
11
+
12
+ export type AdjacencyMap = Record<number, Record<number, number>>;
13
+
14
+ /**
15
+ * **AniviewConfig** — Layout Engine & Gesture Orchestrator
16
+ *
17
+ * Holds the page grid definition, overlap ratios, spring physics, and
18
+ * adjacency rules. It provides two critical pipelines:
19
+ *
20
+ * ### 1. Coordinate Mapping (`getPageOffset`, `register`)
21
+ * Converts a `pageId` into (x, y) world coordinates by walking the layout
22
+ * matrix and accumulating page widths/heights minus overlap ratios. The
23
+ * `register()` method is called during the Aniview bake phase to pre-compute
24
+ * keyframe target positions in world space.
25
+ *
26
+ * ### 2. Gesture Generation (`generateGesture`)
27
+ * Produces a RNGH Pan gesture with:
28
+ * - Axis locking (first-movement direction wins)
29
+ * - Bitmask-based directional locks (1=left, 2=right, 4=up, 8=down)
30
+ * - Edge resistance at world boundaries
31
+ * - Velocity-sensitive neighbor snapping with configurable thresholds
32
+ * - Spring-based snap animation with boundary dampening
33
+ *
34
+ * ### Overlaps
35
+ * The `overlaps` parameter reduces the gap between adjacent pages by a
36
+ * fraction of the viewport size. `{ cols: [0.5] }` means the first and
37
+ * second columns share 50% of the screen width, creating a drawer effect.
38
+ * Each element in the array corresponds to the gap between adjacent
39
+ * rows/columns (so `cols` needs `numCols - 1` values).
40
+ *
41
+ * ### Layout Cache
42
+ * Components can unmount and remount without losing their measured position
43
+ * thanks to `registerLayout()`/`getLayout()`. This enables true virtualization.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * // 3 horizontal pages with the middle one as default
48
+ * const config = new AniviewConfig(
49
+ * [[1, 1, 1]],
50
+ * 1,
51
+ * { LEFT: 0, CENTER: 1, RIGHT: 2 },
52
+ * {},
53
+ * { cols: [0, 0] }, // no overlap
54
+ * );
55
+ * ```
56
+ *
57
+ * @see {@link AniviewProvider} which consumes this config
58
+ */
59
+ export class AniviewConfig implements IAniviewConfig {
60
+ /** The grid layout matrix. `1` = valid page, `0` = empty slot. Rows are vertical, columns are horizontal. */
61
+ public readonly layout: number[][];
62
+ /** Current viewport dimensions */
63
+ // public readonly contextDims: AniviewContextType['dimensions']; // Replaced by getter
64
+ /** The numeric page ID that serves as the world origin (camera starts here) */
65
+ public readonly defaultPage: number;
66
+ /** Optional custom adjacency rules for snapping (overrides grid-based neighbors) */
67
+ public readonly adjacencyGraph: AdjacencyMap;
68
+ /** Map of semantic names to numeric page IDs (e.g., `{ HOME: 0 }`) */
69
+ public readonly pageMap: Record<string, number>;
70
+
71
+ /**
72
+ * Overlap ratios between adjacent rows, expressed as fractions of viewport height (0–1).
73
+ * Length should be `numRows - 1`. A value of 0.3 means 30% vertical overlap.
74
+ * @internal
75
+ */
76
+ private readonly rowOverlaps: number[];
77
+ /**
78
+ * Overlap ratios between adjacent columns, expressed as fractions of viewport width (0–1).
79
+ * Length should be `numCols - 1`. A value of 0.5 means 50% horizontal overlap.
80
+ * @internal
81
+ */
82
+ private readonly colOverlaps: number[];
83
+
84
+ /** Standard physics for snapping animations */
85
+ private springConfig = {
86
+ damping: 30,
87
+ stiffness: 150,
88
+ mass: 0.5,
89
+ overshootClamping: true,
90
+ restDisplacementThreshold: 0.01,
91
+ restSpeedThreshold: 2
92
+ };
93
+
94
+ /** Internal dimension state - mutable for onLayout updates */
95
+ private _contextDims: AniviewContextType['dimensions'];
96
+
97
+ /**
98
+ * CACHE: Stores measured positions of components.
99
+ * This is critical for TRUE VIRTUALIZATION (unmounting).
100
+ * Key: pageId_componentIndex (or similar unique key)
101
+ */
102
+ private layoutCache: Record<string, { x: number; y: number }> = {};
103
+
104
+ /**
105
+ * @param layout - Grid matrix defining page positions (e.g., `[[1, 1]]` for 2 horizontal pages)
106
+ * @param defaultPage - Initial page (numeric ID or semantic name). Defaults to `0`.
107
+ * @param pageMap - Semantic name → numeric ID mapping
108
+ * @param initialDims - Initial viewport dimensions (usually set later via `onLayout`)
109
+ * @param overlaps - Ratio-based overlaps between adjacent pages: `{ cols?: number[], rows?: number[] }`
110
+ * @param providedGraph - Custom adjacency graph for non-grid snapping behavior
111
+ */
112
+ constructor(
113
+ layout: number[][],
114
+ defaultPage: number | string | null = null,
115
+ pageMap: Record<string, number> = {},
116
+ initialDims: Partial<AniviewContextType['dimensions']> = {},
117
+ overlaps: { cols?: number[]; rows?: number[] } = {},
118
+ providedGraph: AdjacencyMap | null = null
119
+ ) {
120
+ this.pageMap = pageMap || {};
121
+ this.layout = layout || [[1]];
122
+ this._contextDims = {
123
+ width: initialDims.width || 0,
124
+ height: initialDims.height || 0,
125
+ offsetX: initialDims.offsetX || 0,
126
+ offsetY: initialDims.offsetY || 0
127
+ };
128
+
129
+ // Resolve semantic default page
130
+ this.defaultPage = this.resolvePageId(defaultPage ?? 0);
131
+ this.adjacencyGraph = providedGraph || {};
132
+
133
+ const numRows = this.layout.length;
134
+ const numCols = this.layout[0]?.length || 0;
135
+
136
+ this.rowOverlaps = new Array(Math.max(0, numRows - 1)).fill(0);
137
+ if (overlaps.rows) overlaps.rows.forEach((v, i) => { if (i < this.rowOverlaps.length) this.rowOverlaps[i] = v; });
138
+
139
+ this.colOverlaps = new Array(Math.max(0, numCols - 1)).fill(0);
140
+ if (overlaps.cols) overlaps.cols.forEach((v, i) => { if (i < this.colOverlaps.length) this.colOverlaps[i] = v; });
141
+ }
142
+
143
+ public registerLayout(componentId: string, layout: { x: number; y: number }) {
144
+ this.layoutCache[componentId] = layout;
145
+ }
146
+
147
+ public getLayout(componentId: string) {
148
+ return this.layoutCache[componentId];
149
+ }
150
+
151
+ get contextDims() {
152
+ return this._contextDims;
153
+ }
154
+
155
+
156
+ public updateDimensions(dims: AniviewContextType['dimensions']) {
157
+ this._contextDims = dims;
158
+ }
159
+
160
+ public updateSpringConfig(config: any) {
161
+ this.springConfig = { ...this.springConfig, ...config };
162
+ }
163
+
164
+ /**
165
+ * Returns the shared physics configuration for snap animations.
166
+ */
167
+ public getSpringConfig() {
168
+ return this.springConfig;
169
+ }
170
+
171
+ /** Gets the global (x, y) offset for a specific page relative to origin. */
172
+ public getPageOffset(pageId: number | string, dims: AniviewContextType['dimensions']) {
173
+ const resolvedId = this.resolvePageId(pageId);
174
+ return AniviewMath.getPageOffset(
175
+ resolvedId,
176
+ this.layout,
177
+ dims,
178
+ this.defaultPage,
179
+ this.rowOverlaps,
180
+ this.colOverlaps
181
+ );
182
+ }
183
+
184
+ public resolvePageId(pageId: number | string): number {
185
+ if (typeof pageId === 'number') return pageId;
186
+ if (this.pageMap[pageId] !== undefined) return this.pageMap[pageId];
187
+
188
+ // Fallback: If it's a string but NOT in the map, try to parse as number
189
+ const parsed = parseInt(pageId, 10);
190
+ return isNaN(parsed) ? 0 : parsed;
191
+ }
192
+
193
+ // To be injected by generateGesture/Provider
194
+ private _currentPageSV: SharedValue<number | string> = makeMutable<number | string>(0);
195
+
196
+ public getCurrentPage(): SharedValue<number | string> {
197
+ return this._currentPageSV;
198
+ }
199
+
200
+ public _setCurrentPageSV(sv: SharedValue<number | string>) {
201
+ this._currentPageSV = sv;
202
+ }
203
+
204
+ /**
205
+ * Pre-calculates absolute coordinates for a component's keyframes.
206
+ * Segregates Spatial (X/Y) frames from Event-based (1D) frames.
207
+ */
208
+ public register(
209
+ pageId: number | string,
210
+ dims: AniviewContextType['dimensions'],
211
+ keyframes?: AniviewFrame[] | Record<string, AniviewFrame>,
212
+ localLayout?: { x: number; y: number }
213
+ ) {
214
+ const resolvedHomeId = this.resolvePageId(pageId);
215
+ const homeOffset = this.getPageOffset(resolvedHomeId, dims);
216
+ const localX = localLayout?.x || 0;
217
+ const localY = localLayout?.y || 0;
218
+
219
+ const bakedFrames: Record<string, BakedFrame> = {};
220
+ const eventLanes: Record<string, BakedFrame[]> = {};
221
+
222
+ if (keyframes) {
223
+ const entries = Array.isArray(keyframes)
224
+ ? keyframes.map((f, i) => ({ key: `f_${i}`, frame: f }))
225
+ : Object.entries(keyframes).map(([k, f]) => ({ key: k, frame: f }));
226
+
227
+ entries.forEach(({ key, frame }) => {
228
+ // Spatial Frame Logic (uses 'page')
229
+ if (frame.page !== undefined || frame.event === undefined) {
230
+ const targetPageId = frame.page !== undefined ? this.resolvePageId(frame.page) : resolvedHomeId;
231
+ const targetOffset = this.getPageOffset(targetPageId, dims);
232
+ bakedFrames[key] = {
233
+ ...frame,
234
+ worldX: targetOffset.x - homeOffset.x,
235
+ worldY: targetOffset.y - homeOffset.y,
236
+ };
237
+ }
238
+
239
+ // Event Frame Logic (uses 'event' and 'value')
240
+ if (frame.event) {
241
+ if (!eventLanes[frame.event]) eventLanes[frame.event] = [];
242
+ eventLanes[frame.event].push({
243
+ ...frame,
244
+ worldX: 0,
245
+ worldY: 0,
246
+ value: frame.value ?? 0
247
+ });
248
+ }
249
+ });
250
+ }
251
+
252
+ // Sort event frames by value for 1D interpolation
253
+ Object.keys(eventLanes).forEach(k => {
254
+ eventLanes[k].sort((a, b) => (a.value || 0) - (b.value || 0));
255
+ });
256
+
257
+ return {
258
+ homeOffset,
259
+ bakedFrames,
260
+ eventLanes,
261
+ localLayout: { x: localX, y: localY }
262
+ };
263
+ }
264
+
265
+
266
+ /** Minimal offset context for direct page references */
267
+ public registerPage(pageId: number | string, dims: AniviewContextType['dimensions']) {
268
+ return {
269
+ offset: this.getPageOffset(pageId, dims),
270
+ dimensions: {
271
+ width: dims.width,
272
+ height: dims.height
273
+ }
274
+ };
275
+ }
276
+
277
+ /** Returns all valid page IDs defined in the layout matrix */
278
+ public getPages(): number[] {
279
+ const pages: number[] = [];
280
+ const rows = this.layout.length;
281
+ if (rows === 0) return [0];
282
+ const cols = this.layout[0].length;
283
+ for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
284
+ for (let colIndex = 0; colIndex < cols; colIndex++) {
285
+ if (this.layout[rowIndex][colIndex] === 1) {
286
+ pages.push(rowIndex * cols + colIndex);
287
+ }
288
+ }
289
+ }
290
+ return pages;
291
+ }
292
+
293
+ /** Map of PageID -> (x, y) coordinates */
294
+ public getPagesMap(dims: AniviewContextType['dimensions']): Record<number, { x: number; y: number }> {
295
+ const map: Record<number, { x: number; y: number }> = {};
296
+ const pages = this.getPages();
297
+ for (let index = 0; index < pages.length; index++) {
298
+ const pageId = pages[index];
299
+ map[pageId] = this.getPageOffset(pageId, dims);
300
+ }
301
+ return map;
302
+ }
303
+
304
+ /** Returns calculated min/max world boundaries for gesture clamping */
305
+ public getWorldBounds(dims: AniviewContextType['dimensions']) {
306
+ return AniviewMath.getWorldBounds(
307
+ this.getPages(),
308
+ this.layout,
309
+ dims,
310
+ this.defaultPage,
311
+ this.rowOverlaps,
312
+ this.colOverlaps
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Generates the core Pan Gesture logic.
318
+ * Uses local closures to ensure UI-thread safety and prevent context loss.
319
+ */
320
+ public generateGesture(
321
+ x: SharedValue<number>,
322
+ y: SharedValue<number>,
323
+ onPageChange?: (pageId: number | string) => void,
324
+ lockMask?: SharedValue<number>,
325
+ simultaneousHandlers?: any,
326
+ gestureEnabled?: SharedValue<boolean>,
327
+ dims?: AniviewContextType['dimensions'],
328
+ isSnapping?: SharedValue<boolean>,
329
+ lastTargetId?: SharedValue<number | string>
330
+ ) {
331
+ // Persistent constants Captured for the UI thread
332
+ const layout = this.layout;
333
+ const contextDims = dims || this._contextDims;
334
+ const defaultPage = this.defaultPage;
335
+ const rowOverlaps = this.rowOverlaps;
336
+ const colOverlaps = this.colOverlaps;
337
+ const pages = this.getPages();
338
+ const bounds = AniviewMath.getWorldBounds(pages, layout, contextDims, defaultPage, rowOverlaps, colOverlaps);
339
+ const pageMap = this.pageMap;
340
+
341
+ /** Edge resistance strength (0.0 = hard wall, 1.0 = no resistance) */
342
+ const RESISTANCE = 0.08;
343
+ const SPRING_CONFIG = this.springConfig;
344
+ const isSingleRow = layout.length <= 1;
345
+
346
+ const screenWidth = contextDims.width;
347
+ const screenHeight = contextDims.height;
348
+ const rowLength = Math.max(1, layout[0]?.length || 0);
349
+
350
+ // Helper to resolve ID on UI thread
351
+ const resolveId = (pid: number | string) => {
352
+ 'worklet';
353
+ if (typeof pid === 'number') return pid;
354
+ if (pageMap && pageMap[pid] !== undefined) return pageMap[pid];
355
+ return 0; // Fallback
356
+ };
357
+
358
+ // Pre-calculate snap points to keep onUpdate/onEnd fast
359
+ const snapPointsProcessed = pages.map(pageId => {
360
+ const offset = AniviewMath.getPageOffset(pageId, layout, contextDims, defaultPage, rowOverlaps, colOverlaps);
361
+ return {
362
+ id: pageId,
363
+ x: offset.x,
364
+ y: offset.y,
365
+ row: Math.floor(pageId / rowLength),
366
+ col: pageId % rowLength
367
+ };
368
+ });
369
+
370
+
371
+ const triggerPageChange = (pageId: number) => {
372
+ 'worklet';
373
+ if (onPageChange) {
374
+ runOnJS(onPageChange)(pageId);
375
+ }
376
+ };
377
+
378
+ const startX = makeMutable(0);
379
+ const startY = makeMutable(0);
380
+ const internalIsSnapping = makeMutable(false);
381
+ const isSnappingVal = isSnapping || internalIsSnapping;
382
+ const activeAxis = makeMutable(0); // 0: none, 1: horizontal, 2: vertical
383
+ const wasDisabled = makeMutable(false);
384
+
385
+ let pan = Gesture.Pan();
386
+ if (simultaneousHandlers) {
387
+ pan = pan.simultaneousWithExternalGesture(simultaneousHandlers);
388
+ }
389
+
390
+ return pan
391
+ .minDistance(10)
392
+ .onBegin(() => {
393
+ 'worklet';
394
+ isSnappingVal.value = false;
395
+ activeAxis.value = 0;
396
+ wasDisabled.value = false;
397
+
398
+ if (isNaN(x.value)) x.value = 0;
399
+ if (isNaN(y.value)) y.value = 0;
400
+
401
+ startX.value = x.value;
402
+ startY.value = y.value;
403
+ })
404
+ .onUpdate((gestureEvent) => {
405
+ 'worklet';
406
+ // Continuous sync to prevent 'jumps' when taking over from children
407
+ const currentResyncX = x.value + gestureEvent.translationX;
408
+ const currentResyncY = y.value + gestureEvent.translationY;
409
+
410
+ if (gestureEnabled && gestureEnabled.value === false) {
411
+ startX.value = currentResyncX;
412
+ startY.value = currentResyncY;
413
+ wasDisabled.value = true;
414
+ return;
415
+ }
416
+
417
+ // Catch transition from disabled to enabled
418
+ if (wasDisabled.value) {
419
+ startX.value = currentResyncX;
420
+ startY.value = currentResyncY;
421
+ wasDisabled.value = false;
422
+ }
423
+
424
+ isSnappingVal.value = true;
425
+
426
+ const dx = Math.abs(gestureEvent.translationX);
427
+ const dy = Math.abs(gestureEvent.translationY);
428
+ const TRANSLATION_THRESHOLD = 3;
429
+
430
+ const isHBlocked = lockMask && (lockMask.value & 1);
431
+ const isVBlocked = lockMask && (lockMask.value & 2);
432
+
433
+ // Lock to an axis after a small movement
434
+ if (activeAxis.value === 0 && (dx > TRANSLATION_THRESHOLD || dy > TRANSLATION_THRESHOLD)) {
435
+ activeAxis.value = dx > dy ? 1 : 2;
436
+ }
437
+
438
+ let newX = startX.value;
439
+ let newY = startY.value;
440
+
441
+ // Apply movement only to the active axis AND if not blocked
442
+ if (activeAxis.value === 1 || activeAxis.value === 0) {
443
+ if (isHBlocked) {
444
+ startX.value = x.value + gestureEvent.translationX;
445
+ } else {
446
+ newX = startX.value - gestureEvent.translationX;
447
+ }
448
+ }
449
+
450
+ if (!isSingleRow && (activeAxis.value === 2 || activeAxis.value === 0)) {
451
+ if (isVBlocked) {
452
+ startY.value = y.value + gestureEvent.translationY;
453
+ } else {
454
+ newY = startY.value - gestureEvent.translationY;
455
+ }
456
+ }
457
+
458
+ // ALIGNMENT ENFORCEMENT for Vertical Swiping
459
+ if (activeAxis.value === 2) {
460
+ newX = startX.value; // Force horizontal to be centered
461
+ }
462
+ // ALIGNMENT ENFORCEMENT for Horizontal Swiping
463
+ if (activeAxis.value === 1) {
464
+ newY = startY.value; // Force vertical to be centered
465
+ }
466
+
467
+
468
+ // NaN Protection
469
+ if (isNaN(newX)) newX = startX.value;
470
+ if (isNaN(newY)) newY = startY.value;
471
+
472
+ // CLAMP: Prevent skipping multiple pages on fast swipes
473
+ // Limit movement to 1.2 pages max in either direction from start position
474
+ const maxSwipeDistance = screenWidth * 1.2;
475
+ const deltaX = newX - startX.value;
476
+ if (Math.abs(deltaX) > maxSwipeDistance) {
477
+ newX = startX.value + (deltaX > 0 ? maxSwipeDistance : -maxSwipeDistance);
478
+ }
479
+
480
+ // Overscroll limits before resistance triggers
481
+ const localLimitX = screenWidth * 1.5;
482
+ const localLimitY = screenHeight * 1.5;
483
+ const lowX = startX.value - localLimitX;
484
+ const highX = startX.value + localLimitX;
485
+
486
+ // World Bounds Resistance
487
+ if (newX < bounds.minX) newX = bounds.minX + (newX - bounds.minX) * RESISTANCE;
488
+ else if (newX > bounds.maxX) newX = bounds.maxX + (newX - bounds.maxX) * RESISTANCE;
489
+
490
+ // Local Velocity Protection
491
+ if (newX < lowX) newX = lowX + (newX - lowX) * RESISTANCE;
492
+ else if (newX > highX) newX = highX + (newX - highX) * RESISTANCE;
493
+
494
+ if (!isSingleRow) {
495
+ const lowY = startY.value - localLimitY;
496
+ const highY = startY.value + localLimitY;
497
+
498
+ if (newY < bounds.minY) newY = bounds.minY + (newY - bounds.minY) * RESISTANCE;
499
+ else if (newY > bounds.maxY) newY = bounds.maxY + (newY - bounds.maxY) * RESISTANCE;
500
+
501
+ if (newY < lowY) newY = lowY + (newY - lowY) * RESISTANCE;
502
+ else if (newY > highY) newY = highY + (newY - highY) * RESISTANCE;
503
+ }
504
+
505
+ x.value = newX;
506
+ y.value = newY;
507
+ })
508
+ .onEnd((gestureEvent) => {
509
+ 'worklet';
510
+ if (gestureEnabled && gestureEnabled.value === false) return;
511
+ if (snapPointsProcessed.length === 0) return;
512
+
513
+ const VELOCITY_THRESHOLD = 200; // Lowered from 500 for high sensitivity
514
+ const distanceThresholdX = screenWidth * 0.08; // Lowered from 0.15 (8% of screen)
515
+ const distanceThresholdY = screenHeight * 0.08; // Lowered from 0.15 (8% of screen)
516
+
517
+ const stageStartX = startX.value;
518
+ const stageStartY = startY.value;
519
+
520
+ // Identify "Anchor Page" for directional snapping (closest point when gesture began)
521
+ let anchorRow = 0, anchorCol = 0, anchorId = -1;
522
+ let minStartDist = Infinity;
523
+ for (let index = 0; index < snapPointsProcessed.length; index++) {
524
+ const snapPoint = snapPointsProcessed[index];
525
+ const dist = Math.sqrt(Math.pow(stageStartX - snapPoint.x, 2) + Math.pow(stageStartY - snapPoint.y, 2));
526
+ if (dist < minStartDist) {
527
+ minStartDist = dist;
528
+ anchorRow = snapPoint.row;
529
+ anchorCol = snapPoint.col;
530
+ anchorId = snapPoint.id;
531
+ }
532
+ }
533
+ // STRICT: Only treat as "founded start" if within 30% of screen width from page center
534
+ // This prevents edge swipes from being misattributed to the neighboring page
535
+ const startFound = minStartDist < screenWidth * 0.3;
536
+
537
+ let targetX = -1;
538
+ let targetY = -1;
539
+ let targetId = -1;
540
+
541
+ const intentRight = gestureEvent.velocityX < -VELOCITY_THRESHOLD || gestureEvent.translationX < -distanceThresholdX;
542
+ const intentLeft = gestureEvent.velocityX > VELOCITY_THRESHOLD || gestureEvent.translationX > distanceThresholdX;
543
+ const intentDown = gestureEvent.velocityY < -VELOCITY_THRESHOLD || gestureEvent.translationY < -distanceThresholdY;
544
+ const intentUp = gestureEvent.velocityY > VELOCITY_THRESHOLD || gestureEvent.translationY > distanceThresholdY;
545
+
546
+ // Neighbor Snapping
547
+ if (startFound && (intentRight || intentLeft || (!isSingleRow && (intentDown || intentUp)))) {
548
+ let targetRow = anchorRow;
549
+ let targetCol = anchorCol;
550
+
551
+ // Only allow snapping on the current active axis
552
+ if (activeAxis.value === 1) {
553
+ if (intentRight) targetCol = anchorCol + 1;
554
+ else if (intentLeft) targetCol = anchorCol - 1;
555
+ } else if (activeAxis.value === 2 && !isSingleRow) {
556
+ if (intentDown) targetRow = anchorRow + 1;
557
+ else if (intentUp) targetRow = anchorRow - 1;
558
+ }
559
+
560
+ for (let index = 0; index < snapPointsProcessed.length; index++) {
561
+ const snapPoint = snapPointsProcessed[index];
562
+ if (snapPoint.row === targetRow && snapPoint.col === targetCol) {
563
+ targetX = snapPoint.x;
564
+ targetY = snapPoint.y;
565
+ targetId = snapPoint.id;
566
+ break;
567
+ }
568
+ }
569
+ }
570
+
571
+ // Distance-based Fallback (constrained by active axis)
572
+ if (targetX === -1) {
573
+ let minDistance = Infinity;
574
+ for (let index = 0; index < snapPointsProcessed.length; index++) {
575
+ const snapPoint = snapPointsProcessed[index];
576
+
577
+ // STAY ON AXIS: If we are swiping vertically, only consider pages in this column.
578
+ // If swiping horizontally, only consider pages in this row.
579
+ if (activeAxis.value === 1 && snapPoint.row !== anchorRow) continue;
580
+ if (activeAxis.value === 2 && snapPoint.col !== anchorCol) continue;
581
+
582
+ const dist = Math.sqrt(Math.pow(x.value - snapPoint.x, 2) + Math.pow(y.value - snapPoint.y, 2));
583
+ if (dist < minDistance) {
584
+ minDistance = dist;
585
+ targetX = snapPoint.x;
586
+ targetY = snapPoint.y;
587
+ targetId = snapPoint.id;
588
+ }
589
+ }
590
+ }
591
+
592
+ // Final safety: if for some reason we still have no target, stay at start
593
+ if (targetId === -1) {
594
+ targetId = anchorId;
595
+ const snapPoint = snapPointsProcessed.find(p => p.id === anchorId);
596
+ if (snapPoint) {
597
+ targetX = snapPoint.x;
598
+ targetY = snapPoint.y;
599
+ }
600
+ }
601
+
602
+ if (targetId !== -1 && targetId !== anchorId) {
603
+ triggerPageChange(targetId);
604
+ }
605
+
606
+ // --- EDGE SNAP HARDENING ---
607
+ // If we are snapping to a boundary, dampen the velocity to prevent "flick bounce".
608
+ const isBoundaryX = targetX <= bounds.minX || targetX >= bounds.maxX;
609
+ const isBoundaryY = targetY <= bounds.minY || targetY >= bounds.maxY;
610
+ const velocityDamping = 0.5;
611
+
612
+ x.value = withSpring(targetX, {
613
+ ...SPRING_CONFIG,
614
+ velocity: isBoundaryX ? -gestureEvent.velocityX * velocityDamping : -gestureEvent.velocityX
615
+ }, (finished) => {
616
+ if (finished) isSnappingVal.value = false;
617
+ });
618
+
619
+ if (!isSingleRow) {
620
+ y.value = withSpring(targetY, {
621
+ ...SPRING_CONFIG,
622
+ velocity: isBoundaryY ? -gestureEvent.velocityY * velocityDamping : -gestureEvent.velocityY
623
+ });
624
+ } else {
625
+ y.value = withSpring(targetY, SPRING_CONFIG);
626
+ }
627
+
628
+ isSnappingVal.value = true;
629
+ })
630
+ .onFinalize((event, success) => {
631
+ 'worklet';
632
+ // If gesture was cancelled/failed (button press) OR didn't move enough to lock axis (tap), do nothing.
633
+ if (!success || activeAxis.value === 0) return;
634
+
635
+ if (gestureEnabled && gestureEnabled.value === false) return;
636
+ if (isSnappingVal.value) return;
637
+
638
+ let minDistance = Infinity;
639
+ let targetX = x.value;
640
+ let targetY = y.value;
641
+ let targetId = -1;
642
+ const snapPointsCount = snapPointsProcessed.length;
643
+
644
+ for (let index = 0; index < snapPointsCount; index++) {
645
+ const snapPoint = snapPointsProcessed[index];
646
+ const dist = Math.sqrt(Math.pow(x.value - snapPoint.x, 2) + Math.pow(y.value - snapPoint.y, 2));
647
+ if (dist < minDistance) {
648
+ minDistance = dist;
649
+ targetX = snapPoint.x;
650
+ targetY = snapPoint.y;
651
+ targetId = snapPoint.id;
652
+ }
653
+ }
654
+
655
+ // --- CONFLICT RESOLUTION ---
656
+ // If the gesture resolution (Snap to targetId) conflicts with a pending programmatic move (lastTargetId),
657
+ // and the gesture was essentially stationary (snapped back to anchor), we yield to the external command.
658
+ if (lastTargetId) {
659
+ const currentProgrammaticId = resolveId(lastTargetId.value);
660
+ const stageStartX = startX.value;
661
+ const stageStartY = startY.value;
662
+
663
+ // Re-calculate start anchor for comparison
664
+ let anchorId = -1;
665
+ let minStartDist = Infinity;
666
+ for (let index = 0; index < snapPointsCount; index++) {
667
+ const snapPoint = snapPointsProcessed[index];
668
+ const dist = Math.sqrt(Math.pow(stageStartX - snapPoint.x, 2) + Math.pow(stageStartY - snapPoint.y, 2));
669
+ if (dist < minStartDist) {
670
+ minStartDist = dist;
671
+ anchorId = snapPoint.id;
672
+ }
673
+ }
674
+
675
+ // If gesture is staying put (target == anchor) BUT the program wants to be elsewhere -> Yield
676
+ if (targetId === anchorId && anchorId !== currentProgrammaticId) {
677
+ return;
678
+ }
679
+ }
680
+
681
+ // Always force snap to resolve any mid-swipe "catch"
682
+ x.value = withSpring(targetX, SPRING_CONFIG, (finished) => {
683
+ if (finished) isSnappingVal.value = false;
684
+ });
685
+ y.value = withSpring(targetY, SPRING_CONFIG);
686
+ });
687
+ }
688
+ }