/** * usePanMotion — one sensor-fed hook that exposes the three motion * signals the first-time-user GUIDANCE surfaces share, so the screen * spins up ONE gyroscope + ONE accelerometer subscription instead of * three independent ones. * * Consumers * - Item 3 (pan how-to / direction arrow) wants `resolvedAxis` to * know whether the user is panning horizontally or vertically. * - Item 4 ("Moving too fast, slow down") wants `panSpeedBucket`. * - Item 6 (lateral-drift → finalize + popup) wants `lateralCm` / * `lateralExceeded`. * * Why one hook (and not three components each subscribing) * `react-native-sensors` is global: every `gyroscope.subscribe` / * `accelerometer.subscribe` adds a listener to the same underlying * native sensor, and `setUpdateIntervalForType` is process-wide. * Three subscribers means three JS callbacks per native sample + * three teardown paths to get right. Funnelling the shared signals * through one hook keeps the sensor wiring in a single place. * * ── Speed bucket (Item 4) ──────────────────────────────────────── * Reuses `PanoramaGuidance`'s gyro logic verbatim (see `bucketFor` * below, lifted from that file): take the dominant rotation axis for * the current pan direction and map |rad/s| onto good / warn / bad. * horizontal pan (portrait, Mode B) → gyro Y dominates. * vertical pan (landscape, Mode A) → gyro X dominates. * Defaults 0.5 / 1.0 rad/s match `PanoramaGuidance`'s SCANS tuning. * * ── Lateral drift (Item 6) ─────────────────────────────────────── * This is the subtle part. `useIMUTranslationGate` integrates the * accelerometer along **device-X**, because in BOTH pan modes the * pan axis maps to device-X (portrait: user left/right; landscape: * device-X has rotated 90° into user up/down). That gate's X * integrator RESETS at every accepted keyframe (and auto-rearms on * each budget fire) — see its header — because it measures * translation-*along*-the-pan between keyframes. * * Lateral drift is the ORTHOGONAL motion: the operator sliding the * phone sideways out of the pan plane. Orthogonal to device-X is * **device-Y**, in both modes. So we integrate device-Y here. * * Crucially this accumulator must measure drift over the WHOLE * capture, not per-keyframe — a slow continuous sideways creep would * never trip a per-keyframe-reset budget. So unlike the gate's * `posX`, our `posY` resets ONLY on `active` false → true (capture * start). It is never reset by keyframe accepts (this hook doesn't * even know about them). * * We borrow the gate's drift-mitigation recipe (per-axis IIR gravity * estimate + per-sample velocity damping + iOS G→m/s² scaling) so the * lateral integrator has the same noise floor characteristics. * * Grace window * A short slide as the operator settles their grip at capture start * shouldn't fire the "you drifted" popup. `lateralExceeded` only * latches once the budget has been *continuously* exceeded for * `LATERAL_GRACE_MS` (default 500 ms). A dip back under budget * resets the grace timer, so a single wobble that crosses and * immediately recrosses the threshold never latches. Once latched * it STAYS latched until the next capture (matches Item 6's * product decision: finalize what's captured, then show the popup — * we don't un-finalize if the phone wobbles back). * * Performance * Gyro at ~30 Hz, accel at ~50 Hz, all integrator state in refs. * `setState` fires only on a *qualitative* change (bucket flips, or * the exceeded latch trips) — never per sample. `lateralCm` is the * one exception consumers may want live; it's exposed via the * returned object but only re-rendered on the throttled tick (see * `LATERAL_EMIT_INTERVAL_MS`) so a debug/HUD readout updates without * a 50 Hz re-render storm. */ export type PanSpeedBucket = 'good' | 'warn' | 'bad'; /** * Pan axis in user-perceived terms: * 'horizontal' → portrait, pan left↔right (Mode B). * 'vertical' → landscape, pan up↕down (Mode A). * Same vocabulary as `PanoramaGuidance`'s `PanAxis`. */ export type PanAxis = 'horizontal' | 'vertical'; export interface UsePanMotionOptions { /** * Subscribe to the sensors only while this is true. Typically the * host's `statusPhase === 'recording'`. Teardown on inactive so * the gyro/accel aren't running the rest of the time the screen is * up. The lateral accumulator zeroes on every false → true edge. */ active: boolean; /** * Force the pan axis instead of auto-detecting from device * orientation. Matches `PanoramaGuidance`'s `axis` prop: hosts * that lock orientation but want the user to pan the orthogonal * axis pass this. Default: auto-detect — 'horizontal' in portrait, * 'vertical' in landscape. */ axis?: PanAxis; /** * Gyro rate (rad/s) at/below which the pan is 'good'. Default 0.5, * same as `PanoramaGuidance`'s SCANS tuning. */ goodMaxRadPerSec?: number; /** * Gyro rate (rad/s) at/below which the pan is 'warn' (above 'good'). * Above it is 'bad'. Default 1.0. */ warnMaxRadPerSec?: number; /** * Lateral (cross-pan) translation budget in CENTIMETRES. Once the * integrated |lateral| exceeds this for `LATERAL_GRACE_MS`, * `lateralExceeded` latches true. Defaults to * {@link DEFAULT_LATERAL_BUDGET_CM}. * * NOTE this governs the ACCELEROMETER (displacement) trigger only. * `lateralExceeded` has a SECOND, independent trigger — the cross-pan * gyro EMA, see {@link lateralTurnRateRadPerSec} — which this budget * does not affect. `0` disables BOTH. */ lateralBudgetCm?: number; /** * Cross-pan ROTATION rate, rad/s, above which `lateralExceeded` * latches. Defaults to {@link DEFAULT_LATERAL_TURN_RAD_PER_SEC} * (0.15 rad/s ≈ 8.6 °/s), i.e. unset reproduces today's behaviour. * * This is the OTHER, historically PRIMARY lateral trigger: an EMA * (τ ≈ 0.4 s) of |gyro cross-axis rate|, entirely independent of the * displacement integrator and of `lateralBudgetCm`. It was tuned * against one field trace (a straight pan smoothed to ~0.04; two * deliberate cross-turns to ~0.3 and ~0.7). Exposed because a stop * attributed to "lateral drift" may in fact be THIS trigger, and * until now there was no way to tune it or tell the two apart — read * `latch=gyro|accel` in the `[panMotion]` telemetry to find out which * one is firing before changing either number. * * `0` (or negative) disables THIS trigger only; `lateralBudgetCm: 0` * disables both. */ lateralTurnRateRadPerSec?: number; /** * Continuous over-threshold dwell, ms, before the ROTATION trigger latches. * Defaults to {@link LATERAL_GRACE_MS} (500 ms) — the same window the * displacement trigger has always used. * * Before v0.26.0 this trigger had NO dwell requirement: it latched on the * first sample over threshold, so a single brief wobble ended the capture * permanently. On a 2026-08-26 device session all five stops came from one * ~400 ms excursion (one on a 0.7 % overshoot), including a capture whose * MEDIAN rotation was lower than eight captures that completed fine — while * every capture stitched at full confidence. * * `0` is the closest this helper gets to the old latch-immediately * behaviour: the dwell clock starts on the first over-threshold sample and * can only latch on a later one, so `0` still costs ONE gyro sample * (~33 ms) rather than zero. */ lateralTurnGraceMs?: number; /** * Absolute cross-pan ANGLE, DEGREES, at which `lateralExceeded` latches. * Defaults to {@link DEFAULT_LATERAL_TURN_ANGLE_DEG} (25). `0` disables * this trigger while still measuring the angle for telemetry. * * Closes the slow-pivot hole that `lateralTurnRateRadPerSec` structurally * cannot: that one is a rate gate, so 6 deg/s of drift accumulates 90 * degrees over 15 s without ever tripping it. Unlike `lateralBudgetCm` * (double-integrated accel — measured ANTI-correlated with ground truth), * a gyro-integrated angle is a single integration and is trustworthy over a * capture: ~0.5 deg of drift after 10 s. */ lateralTurnAngleDeg?: number; /** * Which lateral-drift physics to run. Default `'fused'`. * * 'fused' — subtract the FUSED GRAVITY SENSOR per sample * (`lin = accel - gravity`, i.e. CoreMotion's * `userAcceleration` / Android `TYPE_LINEAR_ACCELERATION` * reconstructed in JS), derive `dt` from each sample's * own `timestamp`, and time-normalise the filter * constants so the detector behaves identically at any * sensor cadence. * 'legacy' — the pre-0.25.4 behaviour, bit-for-bit: a per-sample * IIR gravity estimate and a hardcoded 20 ms `dt`. * * ESCAPE HATCH, not a rollout gate. `'legacy'` cannot tell a wrist * TILT from a sideways SLIDE (a re-projection of gravity onto the * cross-pan axis is arithmetically identical to real acceleration), * so it reads ~1.1 cm of phantom drift per degree of net tilt and * latches on ordinary hand movement. Pass `'legacy'` only to * reproduce an old capture or to back out a device-specific * regression without pinning an old version. */ lateralMotionModel?: LateralMotionModel; /** * Emit the throttled `[panMotion]` diagnostic logs. Default * `__DEV__` — i.e. unset reproduces today's behaviour exactly. * Pass `true` to keep them in a release build while diagnosing a * field report; pass `false` to silence them in development. */ panMotionDebug?: boolean; } export interface UsePanMotionReturn { /** Qualitative pan speed for the dominant gyro axis. */ panSpeedBucket: PanSpeedBucket; /** * Signed lateral (cross-pan) translation since capture start, in * centimetres. Updates on a throttled tick (~10 Hz), not per * sample. Useful for a debug/HUD readout; the latch decision uses * the un-throttled internal value. */ lateralCm: number; /** * `true` once |lateralCm| has exceeded `lateralBudgetCm` * continuously for the grace window. Latching — stays true until * the next capture (`active` false → true). */ lateralExceeded: boolean; /** Resolved pan axis (after auto-detect / `axis` override). */ resolvedAxis: PanAxis; } /** * Lateral-drift physics selector. See `UsePanMotionOptions.lateralMotionModel`. */ export type LateralMotionModel = 'fused' | 'legacy'; /** * Cross-pan drift budget, in centimetres, used when the caller does not * supply one. SINGLE SOURCE OF TRUTH — ``'s `lateralBudgetCm` * prop default imports this rather than repeating the literal, because * two independent copies of a tuning value silently diverge the moment * one is changed (and this one HAS been changed, in v0.25.3). * * v0.25.3: `4` -> `8`, after field reports of the lateral stop firing on * minor drift. Only the budget moved; the detector is unchanged. */ export declare const DEFAULT_LATERAL_BUDGET_CM = 4; /** * Default lateral-drift physics. SINGLE SOURCE OF TRUTH — ``'s * `lateralMotionModel` prop default imports this rather than repeating * the literal (same discipline as `DEFAULT_LATERAL_BUDGET_CM`, enforced * by `__tests__/lateralBudgetDefault.test.ts`). * * v0.25.4: `'fused'`. This is a DEFECT FIX, not a feature — the legacy * integrator double-integrates raw device-Y with an IIR gravity * estimate and therefore cannot distinguish a change in how gravity * PROJECTS onto that axis from real lateral acceleration. Replayed * numerically, a 10° wrist flick over 0.5 s (an ordinary re-grip) * reads 11.65 cm and sits over an 8 cm budget for 7 s — a guaranteed * false latch — while a REAL 20 cm sideways slide reads only 3.05 cm * and never latches. The old model's discrimination is inverted, so * there is no consumer for whom it is preferable. */ export declare const DEFAULT_LATERAL_MOTION_MODEL: LateralMotionModel; /** * Lateral-drift trip point on the SMOOTHED cross-pan gyro rate (EMA of * `|gyro.x|`), in rad/s. * * v0.16 — on-device traces showed a user "moving perpendicular to the arrow" * is really a ROTATION about the cross-pan axis (gyro X), not a sideways * translation — so the old accel double-integration never saw it. But the * raw cross rate is NOISY (dips between samples), so a continuous-over- * threshold dwell reset on every dip and never latched. We instead smooth * `|gyro.x|` with an EMA (rides the dips, gives a ~0.4 s natural dwell) and * latch when the SMOOTHED rate stays above this line. A clean pan smooths * to ~0.04; the user's two cross-turns smoothed to ~0.3 and ~0.7 — so 0.15 * separates them with a comfortable margin. */ export declare const DEFAULT_LATERAL_TURN_RAD_PER_SEC = 0.15; /** * Default absolute cross-pan ANGLE budget, DEGREES — the non-AR twin of * `arLateralRotDeg`. * * {@link DEFAULT_LATERAL_TURN_RAD_PER_SEC} is a RATE gate (0.15 rad/s = * 8.6 deg/s), so it cannot see a slow pivot however far it turns: 6 deg/s * reaches 90 DEGREES of yaw over 15 s and never crosses it. A rate gate * measures how FAST you turn, never how FAR you have turned. * * WHY THIS IS TRUSTWORTHY WHERE THE DISPLACEMENT CHANNEL IS NOT. Angle from * a gyro is a SINGLE integration, so a bias `b` grows the error linearly: * 0.05 deg/s of bias is 0.5 deg after 10 s, negligible against a 25 deg * budget. Distance from an accelerometer is a DOUBLE integration, where the * same class of bias grows as t^2 — 0.02 m/s^2 reaches ~100 cm in the same * 10 s. That is ~1000x worse conditioning, and it is why `lateralBudgetCm` * measured r = -0.28 against ground truth while this can simply be believed. * * The accumulator is SIGNED, so a pan that wanders left then corrects back to * course returns toward zero rather than banking the excursion — correcting * is not an error. */ export declare const DEFAULT_LATERAL_TURN_ANGLE_DEG = 12; /** * Map a signed rotation rate (rad/s) onto the qualitative speed * bucket. Pure — exported for tests. Lifted verbatim from * `PanoramaGuidance.bucketFor` so the two surfaces never diverge. * * Thresholds are INCLUSIVE of the lower band: `|rate| <= good` is * 'good', `|rate| <= warn` is 'warn', otherwise 'bad'. */ export declare function _bucketForRate(rate: number, good: number, warn: number): PanSpeedBucket; /** * Pick the dominant gyro axis value for a pan direction. Mirrors * `PanoramaGuidance`'s `resolvedAxis === 'horizontal' ? y : x`: * horizontal pan (portrait) → gyro Y dominates. * vertical pan (landscape) → gyro X dominates. * Pure — exported for tests. */ export declare function _gyroRateForAxis(axis: PanAxis, gyro: { x: number; y: number; }): number; /** * Resolve the pan axis the same way `PanoramaGuidance` does: * explicit `axis` override wins; otherwise portrait → 'horizontal', * landscape → 'vertical'. Pure — exported for tests. */ export declare function _resolvePanAxis(orientation: 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right', override?: PanAxis): PanAxis; /** * Internal lateral-integrator state. One device axis (device-Y, the * cross-pan axis) integrated to a position, with the same IIR-gravity * + velocity-damping recipe as `useIMUTranslationGate`'s X gate. * * Unlike that gate, `pos` is NEVER reset by keyframe accepts — only by * `resetLateralState` at capture start — so it accumulates total * cross-pan drift across the whole capture. */ /** Which estimator produced the last sample's linear acceleration. */ export type LateralLinSource = 'fused' | 'legacy-iir'; export interface LateralState { /** Integrated cross-pan position, METRES. */ pos: number; /** Integrated cross-pan velocity, m/s. */ vel: number; /** IIR-estimated gravity component on the cross-pan axis (m/s²). */ gravity: number; /** * `true` once the latch has tripped; stays true for the capture. * Mirrors `useIMUTranslationGate`'s `fired`, but here it never * auto-rearms (drift is a one-shot finalize, not a re-trigger). */ exceeded: boolean; /** * Timestamp (ms, performance.now-style) at which |pos| first went * over budget in the current continuous over-budget run, or `null` * if currently under budget. Drives the grace window. */ overBudgetSinceMs: number | null; /** * `timestamp` (epoch ms, as reported by the sensor) of the previous * accelerometer sample, or `null` before the first one. Drives the * real per-sample `dt`. Separate from `overBudgetSinceMs`, which is * a `Date.now()` wall-clock value — the two clocks are deliberately * NOT mixed (see `_sanitizeDt`). */ lastTsMs: number | null; /** Zero-initialised EMA accumulator for the stage-2 bias, m/s². */ biasRaw: number; /** * Fused samples since the fused path was (re-)entered. Drives the * Adam-style de-bias correction `1/(1−(1−k)^n)`, which makes the * zero-seeded EMA behave as a RUNNING MEAN for its first samples * instead of ramping up from zero. */ biasN: number; /** * De-biased stage-2 estimate actually subtracted, m/s². `NaN` on the * legacy path. Telemetry + tests only; never re-read as an input. */ bias: number; /** * Was the last sample integrated with a fused gravity vector? A * false→true edge restarts the stage-2 estimator so re-entry is * stepless (`lin === 0` on the first fused sample). */ usedFused: boolean; /** `dt` actually integrated with on the last sample, seconds. */ lastDtS: number; /** Linear acceleration used on the last sample, m/s². */ lastLin: number; /** Which estimator produced `lastLin`. */ lastSource: LateralLinSource; /** Samples whose `dt` was a GAP and therefore had velocity dropped. */ gapCount: number; } /** * Outcome of one `dt` derivation: the seconds to integrate with, plus * whether this sample followed a GAP (in which case the motion across * the gap was unobserved and carrying velocity through it is * unjustified), plus a slug naming which rule fired — the single most * useful field in the debug telemetry, because it answers "is this * device actually delivering at the rate we asked for?". */ export interface DtSample { dtSec: number; gap: boolean; source: 'first' | 'sensor' | 'burst' | 'nonmonotonic' | 'gap'; } /** * Result of one grace-window latch evaluation: the (possibly latched) * exceeded flag + the (possibly cleared/started) continuous-over-budget * timer. See `_evalGraceLatch`. */ export interface GraceLatchResult { exceeded: boolean; overBudgetSinceMs: number | null; } /** * Pure grace-window latch decision, factored out of the integrator so * the debounce is testable without constructing a physical * acceleration profile. Given whether |pos| is currently over budget, * the clock, and the prior latch/timer state, decide the next state: * * - under budget → clear the timer; never un-latch. * - over budget, no timer → start the timer (note: NOT yet latched). * - over budget, timer old → latch once `now - since >= graceMs`. * - already latched → stays latched forever (one-shot * finalize; see header). * * @param overBudget is |pos| currently over the budget? * @param nowMs monotonic clock, ms * @param prevSinceMs timestamp |pos| first went over in the current * continuous run, or `null` if previously under * @param prevExceeded already-latched flag from the prior sample * @param graceMs continuous dwell required before latching */ export declare function _evalGraceLatch(overBudget: boolean, nowMs: number, prevSinceMs: number | null, prevExceeded: boolean, graceMs: number): GraceLatchResult; /** A fresh integrator, as seeded at every capture start. */ export declare function _freshLateralState(): LateralState; /** * Reset the integrator in place for a new capture. Zeroes position, * velocity, the latch, and the grace timer, and re-arms gravity * seeding. Pure (mutates the passed object, returns it) — exported so * tests can assert the "resets only at capture start" contract. */ export declare function _resetLateralState(s: LateralState): LateralState; /** * Derive a trustworthy integration step from two consecutive sensor * `timestamp`s. Pure — exported for tests. * * CLOCK CHOICE. `dt` comes from the sensor's own `timestamp`; the * grace window and the emit throttle keep using `Date.now()`. They are * deliberately never mixed. Both platforms build `timestamp` as * "wall clock now MINUS the age of this sample", sampled inside the * delivery handler: * * iOS floor((NSDate.now + (item.timestamp - systemUptime)) * 1000) * Android currentTimeMillis() + (event.timestamp - elapsedRealtimeNanos()) / 1e6 * * Both terms are read at handler time, so a LATE handler shifts both * equally and they cancel: bunched deliveries still yield the correct * dt. That is exactly the case a hardcoded 20 ms gets wrong — a * main-thread stall during a pan — which is why the real timestamp is * worth taking. What the construction does NOT give you is a * trustworthy ABSOLUTE value: it is wall-clock based (an NTP step moves * it, possibly backwards), it is truncated to whole milliseconds on * both platforms, and on Android OEMs whose `event.timestamp` is not in * the `elapsedRealtimeNanos` base the absolute value is garbage while * the DELTA stays correct. * * Hence the rule: DELTAS ONLY, and clamp them. * * - no predecessor, or a non-finite timestamp → nominal dt. * - delta <= 0 (clock step, or two events in one truncated ms) * → nominal dt. `dt === 0` stalls position while still decaying * velocity; `dt < 0` runs position BACKWARDS and, with the * time-normalised damping, raises `damp` above 1 and AMPLIFIES * velocity — the one input that can make the integrator diverge. * - delta below the floor → a same-millisecond burst; clamp up. * - delta above the ceiling → a GAP, not a long sample. Integrating * a stale velocity across a 2 s backgrounding injects a phantom * displacement in a single line, so we substitute the nominal dt * AND flag `gap` so the caller can zero velocity. */ export declare function _sanitizeDt(prevTsMs: number | null, tsMs: number, nominalDtSec?: number): DtSample; /** * Extra, OPTIONAL inputs to `_integrateLateralSample`. Deliberately an * 8th trailing optional parameter rather than an options-object * refactor: 12 existing call sites pass 7 positional arguments, and * `ts-jest` runs TRANSPILE-ONLY (`isolatedModules`), so a broken * signature migration would leave `npx jest` GREEN while silently * feeding `budgetM` into `graceMs`. Omitting this parameter yields * the legacy path, bit-for-bit. */ export interface LateralSampleOptions { /** * Cross-pan component of the FUSED GRAVITY vector for this sample, * already unit-scaled to m/s². `null`/`undefined` selects the legacy * per-sample IIR estimate — which is what happens during gravity * warm-up, when the last gravity sample is stale, and forever on a * device that has no fused gravity sensor. */ gravityMps2?: number | null; /** * This sample followed a `dt` gap. Zero velocity: the motion across * the gap was unobserved, so carrying momentum through it is * unjustified. */ afterGap?: boolean; } /** * Advance the lateral integrator by one accelerometer sample. Pure * (mutates + returns the passed state) so the integration math is * unit-testable without a sensor or a React render. * * @param s running integrator state (mutated in place) * @param rawAxis raw cross-pan accel reading for this sample, in the * platform's native unit (G's on iOS, m/s² on * Android) — caller has NOT yet applied `scale` * @param scale unit scale (G_TO_MPS2 on iOS, 1 on Android) * @param dt sample period, seconds * @param budgetM lateral budget, METRES * @param graceMs continuous-over-budget dwell before latching, ms * @param nowMs WALL CLOCK (`Date.now()`) for this sample, ms — * drives the grace window ONLY. Never the sensor * timestamp; see `_sanitizeDt` on why the two clocks * are kept apart. * @param opts optional fused-gravity / gap inputs. OMIT for the * legacy path (bit-for-bit pre-0.25.4 behaviour). * @returns the same `s` (mutated): `pos` is the new cross-pan position * in metres; `exceeded` is the latched flag. * * NOTE the first call only seeds gravity and returns with `pos` * unchanged (matches `useIMUTranslationGate`'s first-sample handling) * — the first reading is assumed to be ~stationary at capture start. */ export declare function _integrateLateralSample(s: LateralState, rawAxis: number, scale: number, dt: number, budgetM: number, graceMs: number, nowMs: number, opts?: LateralSampleOptions): LateralState; export declare function usePanMotion({ active, axis, goodMaxRadPerSec, warnMaxRadPerSec, lateralBudgetCm, lateralTurnRateRadPerSec, lateralTurnGraceMs, lateralTurnAngleDeg, lateralMotionModel, panMotionDebug, }: UsePanMotionOptions): UsePanMotionReturn; //# sourceMappingURL=usePanMotion.d.ts.map