wip(mobile): checkpoint the app shell and vision mockup in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 05:27:50 +00:00
1 parent a8b7680c7a
commit e532c5efb6
56 files changed
+23645

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
/** Rear camera: preview, still photo (JPEG) and short video (MediaRecorder). */
export function createCamera(video) {
let stream = null;
let recorder = null;
let chunks = [];
async function start() {
if (stream) return stream;
if (!navigator.mediaDevices?.getUserMedia)
throw new Error('Camera API unavailable (needs https)');
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1440 },
},
});
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await video.play().catch(() => {});
return stream;
}
function stop() {
if (recorder && recorder.state !== 'inactive') recorder.stop();
stream?.getTracks().forEach((t) => t.stop());
stream = null;
video.srcObject = null;
}
function info() {
const t = stream?.getVideoTracks()[0];
if (!t) return null;
const s = t.getSettings?.() || {};
return {
label: t.label || 'camera',
width: s.width,
height: s.height,
facingMode: s.facingMode || null,
frameRate: s.frameRate ? Math.round(s.frameRate) : null,
};
}
/** Grab the current frame as a JPEG blob at the sensor's resolution. */
async function photo() {
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h) throw new Error('Camera not ready');
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(video, 0, 0, w, h);
return new Promise((resolve, reject) =>
c.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Encode failed'))),
'image/jpeg',
0.92,
),
);
}
function canRecord() {
return !!stream && typeof MediaRecorder !== 'undefined';
}
function startVideo() {
if (!canRecord()) throw new Error('Video recording unavailable');
const types = [
'video/mp4;codecs=avc1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
];
const mimeType = types.find((t) => MediaRecorder.isTypeSupported?.(t));
chunks = [];
recorder = new MediaRecorder(
stream,
mimeType ? { mimeType, videoBitsPerSecond: 4e6 } : undefined,
);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.start(250);
}
function stopVideo() {
return new Promise((resolve) => {
if (!recorder || recorder.state === 'inactive') return resolve(null);
recorder.onstop = () =>
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
recorder.stop();
});
}
return {
start,
stop,
info,
photo,
startVideo,
stopVideo,
canRecord,
get active() {
return !!stream;
},
};
}
+93
View File
@@ -0,0 +1,93 @@
/** Minimal promise wrapper over the device's IndexedDB for keys and captures. */
const DB_NAME = 'gev-mobile';
const DB_VERSION = 1;
let dbp = null;
export function openDb() {
if (dbp) return dbp;
dbp = new Promise((resolve, reject) => {
if (!('indexedDB' in globalThis)) {
reject(new Error('IndexedDB is unavailable'));
return;
}
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('keys'))
db.createObjectStore('keys', { keyPath: 'id' });
if (!db.objectStoreNames.contains('captures')) {
const s = db.createObjectStore('captures', { keyPath: 'id' });
s.createIndex('seq', 'seq');
}
if (!db.objectStoreNames.contains('media'))
db.createObjectStore('media', { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
dbp.catch(() => (dbp = null));
return dbp;
}
function wrap(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function get(store, key) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).get(key));
}
export async function put(store, value) {
const db = await openDb();
return wrap(db.transaction(store, 'readwrite').objectStore(store).put(value));
}
export async function del(store, key) {
const db = await openDb();
return wrap(
db.transaction(store, 'readwrite').objectStore(store).delete(key),
);
}
export async function all(store) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).getAll());
}
/** Write a capture bundle and its media atomically. */
export async function putCapture(bundle, mediaBlob) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(['captures', 'media'], 'readwrite');
tx.objectStore('captures').put(bundle);
tx.objectStore('media').put({ id: bundle.id, blob: mediaBlob });
tx.oncomplete = () => resolve(bundle);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function lastCapture() {
const db = await openDb();
return new Promise((resolve, reject) => {
const req = db
.transaction('captures')
.objectStore('captures')
.index('seq')
.openCursor(null, 'prev');
req.onsuccess = () => resolve(req.result?.value || null);
req.onerror = () => reject(req.error);
});
}
export async function captureBySeq(seq) {
const db = await openDb();
return wrap(
db.transaction('captures').objectStore('captures').index('seq').get(seq),
);
}
+384
View File
@@ -0,0 +1,384 @@
/**
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
* Enclave + C2PA flow:
*
* media → sha256
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
* context { device, ua, screen, timezone, network-time-offset, … }
* prev hash of this device's previous bundle (hash chain)
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
* is generated non-extractable and kept in IndexedDB.
*
* None of this proves truth. It raises the cost of lying.
*/
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
const subtle = globalThis.crypto?.subtle;
const enc = new TextEncoder();
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
export function canonical(value) {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
return JSON.stringify(value ?? null);
}
if (Array.isArray(value))
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
const keys = Object.keys(value)
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
const normalize = (v) => JSON.parse(canonical(v));
export const toHex = (buf) =>
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
const fromHex = (hex) =>
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const fromB64url = (s) => {
const b = atob(
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
);
return Uint8Array.from(b, (c) => c.charCodeAt(0));
};
export async function sha256Hex(data) {
const bytes =
typeof data === 'string'
? enc.encode(data)
: data instanceof Blob
? await data.arrayBuffer()
: data;
return toHex(await subtle.digest('SHA-256', bytes));
}
function assertCrypto() {
if (!subtle)
throw new Error('WebCrypto needs a secure context (https or localhost)');
}
/** The device signing key: generated once, private half non-extractable. */
export async function deviceKey() {
assertCrypto();
const stored = await get('keys', 'device').catch(() => null);
if (stored?.privateKey && stored?.publicJwk) return stored;
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', pair.publicKey);
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
const rec = {
id: 'device',
privateKey: pair.privateKey,
publicKey: pair.publicKey,
publicJwk,
keyId,
createdAt: new Date().toISOString(),
extractable: pair.privateKey.extractable,
};
await put('keys', rec);
return rec;
}
/** Estimate the device clock's offset from the server's Date header (±1 s). */
export async function networkTimeOffset() {
try {
const t0 = Date.now();
const r = await fetch(location.pathname, {
method: 'HEAD',
cache: 'no-store',
});
const t1 = Date.now();
const d = Date.parse(r.headers.get('date') || '');
if (!Number.isFinite(d)) return null;
return Math.round(d + 500 - (t0 + t1) / 2);
} catch {
return null;
}
}
/**
* Seal a capture.
* @param {{ media: Blob, session: object, context: object }} input
* @returns {Promise<object>} the stored bundle
*/
export async function seal({ media, session, context }) {
assertCrypto();
const key = await deviceKey();
const mediaHash = await sha256Hex(media);
const last = await lastCapture().catch(() => null);
const seq = (last?.seq || 0) + 1;
const core = normalize({
v: 1,
seq,
media: {
sha256: mediaHash,
type: media.type || 'application/octet-stream',
bytes: media.size,
},
session,
context,
prev: last?.hash || null,
keyId: key.keyId,
publicKey: key.publicJwk,
sealedAt: new Date().toISOString(),
});
const hash = await sha256Hex(canonical(core));
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
const bundle = {
...core,
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
hash,
alg: 'ES256',
signature: b64url(sig),
};
await putCapture(bundle, media);
return bundle;
}
const median = (a) => {
if (!a.length) return NaN;
const s = [...a].sort((x, y) => x - y);
return s[Math.floor(s.length / 2)];
};
/** Core fields covered by the hash (everything but the outputs). */
function coreOf(bundle) {
const { id, hash, alg, signature, ...core } = bundle;
void id;
void hash;
void alg;
void signature;
return core;
}
/**
* Verify a bundle: hash, signature, chain, sensor plausibility.
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
*/
export async function verify(bundle, mediaBlob) {
assertCrypto();
const checks = [];
const add = (id, label, status, detail) =>
checks.push({ id, label, status, detail });
// 1. Media hash
if (mediaBlob) {
const h = await sha256Hex(mediaBlob);
add(
'media',
'Media hash',
h === bundle.media?.sha256 ? 'pass' : 'fail',
h === bundle.media?.sha256
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
);
} else
add('media', 'Media hash', 'warn', 'Media not available on this device');
// 2. Bundle hash
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
const hashOk = recomputed === bundle.hash;
add(
'hash',
'Bundle hash',
hashOk ? 'pass' : 'fail',
hashOk
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
: 'Bundle was modified after sealing',
);
// 3. Signature
try {
const pub = await subtle.importKey(
'jwk',
{ ...bundle.publicKey, ext: true },
ALG,
true,
['verify'],
);
const ok = await subtle.verify(
SIGN,
pub,
fromB64url(bundle.signature),
fromHex(bundle.hash),
);
const mine = await deviceKey().catch(() => null);
const same = mine?.keyId === bundle.keyId;
add(
'signature',
'Signature',
ok ? (same ? 'pass' : 'warn') : 'fail',
ok
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
: 'Signature does not verify against the embedded public key',
);
} catch (err) {
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
}
// 4. Chain
if (bundle.seq === 1)
add(
'chain',
'Hash chain',
bundle.prev === null ? 'pass' : 'fail',
bundle.prev === null
? 'Genesis capture for this device key'
: 'First capture claims a predecessor',
);
else {
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
if (!prev)
add(
'chain',
'Hash chain',
'warn',
`Capture #${bundle.seq - 1} is not on this device`,
);
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
add(
'chain',
'Hash chain',
'pass',
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
);
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
}
// 5. IMU plausibility
const imu = bundle.session?.imu || [];
if (imu.length < 5)
add(
'imu',
'Motion trace',
'warn',
imu.length
? `Only ${imu.length} samples`
: 'No motion sensors were available',
);
else {
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
const rate = imu.length / Math.max(0.001, dur);
let mono = true;
for (let i = 1; i < imu.length; i += 1)
if (imu[i].t < imu[i - 1].t) mono = false;
const mags = imu
.filter((s) => Number.isFinite(s.ax))
.map((s) => Math.hypot(s.ax, s.ay, s.az));
const g = median(mags);
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
const sd = Math.sqrt(
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
);
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
const alive = sd > 0.004;
// Pitch from gravity should agree with the orientation β.
let tiltAgree = null;
const withBoth = imu.filter(
(s) =>
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
);
if (withBoth.length > 4) {
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const diffs = withBoth.map((s) => {
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
return Math.min(d(pitchG, s.β), d(flipped, s.β));
});
tiltAgree = median(diffs) < 20;
}
const status =
!mono || gravityOk === false
? 'fail'
: alive && tiltAgree !== false
? 'pass'
: 'warn';
add(
'imu',
'Motion trace',
status,
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
);
}
// 6. GPS plausibility
const gps = bundle.session?.gps || [];
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
else {
const s0 = Date.parse(bundle.session.start);
const s1 = Date.parse(bundle.session.end);
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
let maxSpeed = 0;
for (let i = 1; i < gps.length; i += 1) {
const a = gps[i - 1];
const b = gps[i];
const dt = (b.t - a.t) / 1000;
if (dt > 0.5) {
const R = 6371e3;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
}
}
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
const last = gps[gps.length - 1];
// The clock's timezone should be roughly where the fix says we are.
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
const solarH = last.lon / 15;
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
const speedOk = maxSpeed < 300;
const status =
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
add(
'gps',
'Location',
status,
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
);
}
// 7. Heading
const hd = bundle.session?.heading;
if (hd && Number.isFinite(hd.deg))
add(
'heading',
'Heading',
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
);
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
// 8. Clock
const off = bundle.context?.networkTimeOffsetMs;
const sealedVsEnd =
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
add(
'clock',
'Clock',
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
? 'warn'
: clockOk
? 'pass'
: 'warn',
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
);
return { ok: !checks.some((c) => c.status === 'fail'), checks };
}
+371
View File
@@ -0,0 +1,371 @@
/**
* Device sensors for Look and Witness: motion/orientation (with iOS
* permission on a user gesture), compass heading, camera pose, GPS watch,
* and a session recorder for the sealed IMU trace.
*/
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
/** iOS 13+: must be called from a user gesture. */
export async function requestMotionPermission() {
const asks = [];
if (
typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function'
)
asks.push(DeviceOrientationEvent.requestPermission());
if (
typeof DeviceMotionEvent !== 'undefined' &&
typeof DeviceMotionEvent.requestPermission === 'function'
)
asks.push(DeviceMotionEvent.requestPermission());
if (!asks.length)
return typeof DeviceOrientationEvent === 'undefined'
? 'unsupported'
: 'granted';
try {
const res = await Promise.all(asks);
return res.every((r) => r === 'granted') ? 'granted' : 'denied';
} catch {
return 'denied';
}
}
/** W3C Z-X'-Y'' rotation matrix (device → East-North-Up). */
export function rotation(alpha, beta, gamma) {
const a = alpha * D2R;
const b = beta * D2R;
const g = gamma * D2R;
const cA = Math.cos(a);
const sA = Math.sin(a);
const cB = Math.cos(b);
const sB = Math.sin(b);
const cG = Math.cos(g);
const sG = Math.sin(g);
return [
[cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],
[cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],
[-cB * sG, sB, cB * cG],
];
}
/** Back-camera pose in ENU: forward / right / up unit vectors + azimuth/elevation. */
export function cameraPose(alpha, beta, gamma, screenAngle = 0) {
const R = rotation(alpha, beta, gamma);
const col = (i) => [R[0][i], R[1][i], R[2][i]];
const fwd = col(2).map((v) => -v);
let right = col(0);
let up = col(1);
if (screenAngle) {
// Landscape: rotate the screen basis about the forward axis.
const s = screenAngle * D2R;
const r2 = right.map((v, i) => v * Math.cos(s) + up[i] * Math.sin(s));
const u2 = up.map((v, i) => v * Math.cos(s) - right[i] * Math.sin(s));
right = r2;
up = u2;
}
const az = (Math.atan2(fwd[0], fwd[1]) * R2D + 360) % 360;
const el = Math.asin(Math.max(-1, Math.min(1, fwd[2]))) * R2D;
const roll = Math.atan2(right[2], up[2]) * R2D;
return { fwd, right, up, az, el, roll };
}
/** Pose from a simulated yaw/pitch (desktop drag-to-look). */
export function poseFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
const fwd = [
Math.cos(e) * Math.sin(a),
Math.cos(e) * Math.cos(a),
Math.sin(e),
];
const right = [Math.cos(a), -Math.sin(a), 0];
const up = [
right[1] * fwd[2] - right[2] * fwd[1],
right[2] * fwd[0] - right[0] * fwd[2],
right[0] * fwd[1] - right[1] * fwd[0],
];
return { fwd, right, up, az: ((az % 360) + 360) % 360, el, roll: 0 };
}
export function createSensors() {
const state = {
orientation: null, // {alpha,beta,gamma,absolute}
motion: null, // {ax,ay,az,lx,ly,lz,rr}
compass: null, // degrees, iOS webkitCompassHeading
compassAccuracy: null,
fix: null, // {t,lat,lon,acc,alt,heading,speed}
geoError: null,
hasMotion: false,
hasOrientation: false,
absolute: false,
};
const trace = []; // live |a| for the HUD
let rec = null;
let geoWatch = null;
let alphaOffset = 0;
let listeners = [];
function onOrient(e) {
if (e.alpha == null && e.beta == null) return;
state.hasOrientation = true;
const absolute =
e.absolute === true || e.type === 'deviceorientationabsolute';
if (absolute) state.absolute = true;
if (
!absolute &&
state.absolute &&
e.type === 'deviceorientation' &&
e.webkitCompassHeading == null
)
return;
state.orientation = {
alpha: e.alpha ?? 0,
beta: e.beta ?? 0,
gamma: e.gamma ?? 0,
absolute,
};
if (e.webkitCompassHeading != null) {
state.compass = e.webkitCompassHeading;
state.compassAccuracy = e.webkitCompassAccuracy;
// Align the arbitrary iOS alpha with magnetic north, smoothly.
// az = −α for an upright camera, so α + (raw − compass) points at north.
const raw = cameraPose(e.alpha, e.beta, e.gamma).az;
const target = ((raw - e.webkitCompassHeading + 540) % 360) - 180;
alphaOffset += (((target - alphaOffset + 540) % 360) - 180) * 0.15;
}
if (rec) sample();
}
function onMotion(e) {
const g = e.accelerationIncludingGravity;
const l = e.acceleration;
if (!g || g.x == null) return;
state.hasMotion = true;
state.motion = {
ax: g.x,
ay: g.y,
az: g.z,
lx: l?.x,
ly: l?.y,
lz: l?.z,
rr: e.rotationRate
? [e.rotationRate.alpha, e.rotationRate.beta, e.rotationRate.gamma]
: null,
};
const mag = Math.hypot(g.x, g.y, g.z);
trace.push({ t: performance.now(), v: mag });
while (trace.length && performance.now() - trace[0].t > 5000) trace.shift();
if (rec) sample();
}
let lastSampleT = 0;
function sample() {
const now = performance.now();
if (now - lastSampleT < 15) return; // ≤ ~66 Hz
lastSampleT = now;
const o = state.orientation || {};
const m = state.motion || {};
rec.imu.push({
t: Date.now() - rec.t0Wall,
α: round(o.alpha),
β: round(o.beta),
γ: round(o.gamma),
ax: round(m.ax, 3),
ay: round(m.ay, 3),
az: round(m.az, 3),
});
if (rec.imu.length > 4000) rec.imu.shift();
}
function round(v, d = 2) {
return Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null;
}
function on(target, type, fn) {
target.addEventListener(type, fn, { passive: true });
listeners.push(() => target.removeEventListener(type, fn));
}
function start() {
stop();
if ('ondeviceorientationabsolute' in window)
on(window, 'deviceorientationabsolute', onOrient);
on(window, 'deviceorientation', onOrient);
on(window, 'devicemotion', onMotion);
if ('geolocation' in navigator) {
geoWatch = navigator.geolocation.watchPosition(
(p) => {
state.geoError = null;
state.fix = {
t: p.timestamp || Date.now(),
lat: p.coords.latitude,
lon: p.coords.longitude,
acc: p.coords.accuracy,
alt: p.coords.altitude,
heading: p.coords.heading,
speed: p.coords.speed,
};
if (rec)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
},
(err) => (state.geoError = err.message || 'Location unavailable'),
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 },
);
}
}
function stop() {
listeners.forEach((f) => f());
listeners = [];
if (geoWatch != null) navigator.geolocation.clearWatch(geoWatch);
geoWatch = null;
}
/** Current back-camera pose, or null without orientation sensors. */
function pose(screenAngle = screenOrientationAngle()) {
const o = state.orientation;
if (!o) return null;
const alpha = state.compass != null ? o.alpha + alphaOffset : o.alpha;
return cameraPose(alpha, o.beta, o.gamma, screenAngle);
}
function headingInfo() {
const p = pose();
if (!p) return null;
return {
deg: Math.round(p.az * 10) / 10,
pitch: Math.round(p.el * 10) / 10,
roll: Math.round(p.roll * 10) / 10,
source:
state.compass != null
? 'compass'
: state.absolute
? 'absolute'
: 'relative',
};
}
return {
state,
trace,
start,
stop,
pose,
headingInfo,
/** Begin recording a session (IMU + GPS). Keeps a pre-roll of what's already live. */
beginSession() {
rec = {
t0Wall: Date.now(),
start: new Date().toISOString(),
imu: [],
gps: [],
};
if (state.fix)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return rec;
},
endSession() {
if (!rec) return null;
const r = rec;
rec = null;
// Keep the last 20 s of motion before the shutter.
const cutoff = Date.now() - r.t0Wall - 20000;
const imu = r.imu.filter((s) => s.t >= cutoff);
if (!r.gps.length && state.fix)
r.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return {
start: r.start,
end: new Date().toISOString(),
imu,
gps: r.gps,
heading: headingInfo(),
};
},
get recording() {
return !!rec;
},
};
}
export function screenOrientationAngle() {
const a = screen.orientation?.angle ?? window.orientation ?? 0;
return Number(a) || 0;
}
/* ---------- AR geometry ---------- */
const A = 6378137;
const E2 = 6.69437999014e-3;
function ecef(lat, lon, h) {
const la = lat * D2R;
const lo = lon * D2R;
const N = A / Math.sqrt(1 - E2 * Math.sin(la) ** 2);
return [
(N + h) * Math.cos(la) * Math.cos(lo),
(N + h) * Math.cos(la) * Math.sin(lo),
(N * (1 - E2) + h) * Math.sin(la),
];
}
/** Azimuth, elevation (deg) and slant range (m) from an observer to a target. */
export function lookAngles(obs, tgt) {
const o = ecef(obs.lat, obs.lon, obs.alt || 0);
const t = ecef(tgt.lat, tgt.lon, tgt.altM || 0);
const d = [t[0] - o[0], t[1] - o[1], t[2] - o[2]];
const la = obs.lat * D2R;
const lo = obs.lon * D2R;
const e = -Math.sin(lo) * d[0] + Math.cos(lo) * d[1];
const n =
-Math.sin(la) * Math.cos(lo) * d[0] -
Math.sin(la) * Math.sin(lo) * d[1] +
Math.cos(la) * d[2];
const u =
Math.cos(la) * Math.cos(lo) * d[0] +
Math.cos(la) * Math.sin(lo) * d[1] +
Math.sin(la) * d[2];
const range = Math.hypot(e, n, u);
return {
az: (Math.atan2(e, n) * R2D + 360) % 360,
el: Math.asin(u / range) * R2D,
range,
enu: [e / range, n / range, u / range],
};
}
/** Project an ENU unit direction into screen pixels for a pinhole camera. */
export function projectDir(dir, pose, W, H, vfovDeg) {
const x =
dir[0] * pose.right[0] + dir[1] * pose.right[1] + dir[2] * pose.right[2];
const y = dir[0] * pose.up[0] + dir[1] * pose.up[1] + dir[2] * pose.up[2];
const z = dir[0] * pose.fwd[0] + dir[1] * pose.fwd[1] + dir[2] * pose.fwd[2];
const fy = H / 2 / Math.tan((vfovDeg / 2) * D2R);
if (z <= 0.01)
return { behind: true, x: W / 2 + x * 1e4, y: H / 2 - y * 1e4 };
return { behind: false, x: W / 2 + (x / z) * fy, y: H / 2 - (y / z) * fy, z };
}
export function dirFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
return [Math.cos(e) * Math.sin(a), Math.cos(e) * Math.cos(a), Math.sin(e)];
}