diff --git a/mobile/capture/camera.js b/mobile/capture/camera.js new file mode 100644 index 000000000..206d373b1 --- /dev/null +++ b/mobile/capture/camera.js @@ -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; + }, + }; +} diff --git a/mobile/capture/db.js b/mobile/capture/db.js new file mode 100644 index 000000000..b3f9677fa --- /dev/null +++ b/mobile/capture/db.js @@ -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), + ); +} diff --git a/mobile/capture/provenance.js b/mobile/capture/provenance.js new file mode 100644 index 000000000..73f39ba07 --- /dev/null +++ b/mobile/capture/provenance.js @@ -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} 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 }; +} diff --git a/mobile/capture/sensors.js b/mobile/capture/sensors.js new file mode 100644 index 000000000..9ca2e8785 --- /dev/null +++ b/mobile/capture/sensors.js @@ -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)]; +} diff --git a/mobile/index.html b/mobile/index.html new file mode 100644 index 000000000..1c2120579 --- /dev/null +++ b/mobile/index.html @@ -0,0 +1,90 @@ + + + + + + God's Eye + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + +
+ +
+ + + diff --git a/mobile/main.js b/mobile/main.js new file mode 100644 index 000000000..1d8a731b2 --- /dev/null +++ b/mobile/main.js @@ -0,0 +1,363 @@ +/** + * God's Eye — mobile shell. + * Boots the five tabs, mounts the one persistent globe (lazy), and wires + * the feed, the card → world transition, the globe chrome, Capture, + * Threads and You together. + */ + +import { + $, + store, + applyMotionPref, + reducedMotion, + toast, + haptic, +} from './ui/dom.js'; +import { icon } from './ui/icons.js'; +import { createTabBar } from './ui/tabbar.js'; +import { mountGlobe, contractReport } from './ui/globeHost.js'; +import { createFeed } from './ui/feed.js'; +import { createWorld } from './ui/world.js'; +import { createGlobeChrome } from './ui/globeChrome.js'; +import { createThreads } from './ui/threads.js'; +import { createYou } from './ui/you.js'; +import { createSearch } from './ui/search.js'; +import { createInspector } from './ui/inspector.js'; +import { createNarrative } from './ui/narrative.js'; + +const app = $('#app'); +const stage = $('#stage'); +const views = { + now: $('#view-now'), + globe: $('#view-globe'), + threads: $('#view-threads'), + you: $('#view-you'), + capture: $('#view-capture'), +}; + +applyMotionPref(); +const isTwoPane = () => + getComputedStyle(app).getPropertyValue('--two-pane').trim() === '1'; + +/* ---------------- Globe (lazy, persistent) ---------------- */ +let globe = null; +let situationsCache = null; +const globeReady = mountGlobe($('#globe'), { quality: store.get('quality') }) + .then((g) => { + globe = g; + app.dataset.engine = g.kind; + delete app.dataset.booting; + return g; + }) + .catch((err) => { + console.error('Globe failed to mount', err); + throw err; + }); + +async function getSituations({ fresh = false } = {}) { + const g = await globeReady; + if (!situationsCache || fresh) situationsCache = await g.situations(); + return situationsCache; +} +const situationById = (id) => + situationsCache?.find((s) => String(s.id) === String(id)) || null; + +/* ---------------- Tabs ---------------- */ +let current = 'now'; +const tabbar = createTabBar($('#tabbar'), { + onSelect: (tab, { reselect }) => { + if (reselect) { + const v = views[tab]; + if (v?.scrollTop > 0) + v.scrollTo({ top: 0, behavior: reducedMotion() ? 'auto' : 'smooth' }); + else if (tab === 'globe') globe?.home(); + return; + } + selectTab(tab); + }, + onSearch: () => search?.open(), + onAccessory: (s) => openSituation(s), +}); + +let capture = null; +let you = null; +let world = null; +let chrome = null; +let threads = null; +let search = null; +let inspector = null; +let narrative = null; + +function applyTab(tab) { + const prev = current; + current = tab; + app.dataset.tab = tab; + const two = isTwoPane(); + for (const [id, el] of Object.entries(views)) { + if (id === 'capture') el.hidden = tab !== 'capture'; + else if (id === 'globe') el.hidden = !(two && tab === 'globe'); + else el.hidden = id !== tab && !(two && tab === 'capture' && id === prev); + } + tabbar.select(tab); + if (tab === 'capture') { + globe?.pause(); + capture?.enter(); + } else { + if (prev === 'capture') capture?.leave(); + updateGlobeActivity(); + } + if (tab === 'you') you?.render(); + if (tab === 'globe' && two) chrome?.renderLeadView(); + if (tab !== 'globe') chrome?.hidePick(); +} + +function selectTab(tab) { + if (tab === current) return; + if (world?.current && !isTwoPane()) world.close(); + if (narrative?.active) narrative.stop(); + const vt = document.startViewTransition && !reducedMotion(); + if (vt) document.startViewTransition(() => applyTab(tab)); + else { + applyTab(tab); + const v = views[tab]; + if (v && !v.hidden) { + v.classList.remove('view-enter'); + void v.offsetWidth; + v.classList.add('view-enter'); + } + } +} + +/** Keep the globe rendering only when someone can see it. */ +let pauseTimer = 0; +function updateGlobeActivity() { + if (!globe) return; + clearTimeout(pauseTimer); + const visible = + document.visibilityState === 'visible' && + current !== 'capture' && + (isTwoPane() || current === 'globe' || app.classList.contains('is-world')); + if (visible) globe.resume(); + else pauseTimer = setTimeout(() => globe.pause(), 700); +} +document.addEventListener('visibilitychange', updateGlobeActivity); +new MutationObserver(updateGlobeActivity).observe(app, { + attributes: true, + attributeFilter: ['class'], +}); + +/* ---------------- Feed ---------------- */ +const feed = createFeed(views.now, { + getSituations: () => getSituations({ fresh: true }), + onOpen: (s, card) => openSituation(s, card), + onLocate: () => + new Promise((resolve) => { + if (!navigator.geolocation) return resolve(null); + navigator.geolocation.getCurrentPosition( + (p) => resolve({ lat: p.coords.latitude, lon: p.coords.longitude }), + () => { + toast('Location unavailable — sorting from San Francisco', { + icon: icon('location'), + }); + resolve(null); + }, + { maximumAge: 600000, timeout: 10000 }, + ); + }), + bindScroll: (el) => tabbar.bindScroll(el), +}); + +function openSituation(s, card = null) { + if (!world) return; + if (current === 'capture') applyTab('now'); + if (narrative?.active) narrative.stop(); + world.open(s, card); +} + +function refreshAccessory() { + const followed = store.get('followed') || []; + const s = followed.map((id) => situationById(id)).find(Boolean) || null; + tabbar.setAccessory(s); +} + +/* ---------------- Boot dependent modules ---------------- */ +globeReady + .then(async (g) => { + world = createWorld({ + app, + stage, + globe: g, + getCard: (id) => feed.cardFor(id), + isTwoPane, + onThread: (s) => openThreadFor(s), + onFollowChange: refreshAccessory, + }); + world.on('open', (s) => feed.select(s.id)); + world.on('close', () => feed.select(null)); + chrome = createGlobeChrome({ + chrome: $('#globe-chrome'), + stage, + leadView: views.globe, + globe: g, + onOpenSituation: (s) => openSituation(s), + onThread: (s) => openThreadFor(s), + getSituations, + isTwoPane, + }); + narrative = createNarrative({ + app, + chrome: $('#globe-chrome'), + globe: g, + ribbon: chrome.ribbon, + getSituation: situationById, + }); + threads = createThreads(views.threads, { + bindScroll: (el) => tabbar.bindScroll(el), + onPlay: (thread) => { + if (world.current) world.close(); + narrative.play(thread); + }, + onShowOnGlobe: async (thread, o) => { + const s = situationById(thread.id); + if (s) await world.open(s); + else { + app.classList.add('is-world'); + g.resume(); + } + world.sheet.setDetent?.('peek'); + g.flyTo({ + lon: o.lon, + lat: o.lat, + height: 7000, + pitch: -40, + duration: 2, + }); + }, + }); + inspector = createInspector(); + you = createYou(views.you, { + bindScroll: (el) => tabbar.bindScroll(el), + getSituations, + onOpenSituation: (s) => openSituation(s), + onVerify: (b) => inspector.open(b), + onCredits: () => chrome.openCredits(), + }); + search = createSearch({ + getSituations, + globe: g, + onOpenSituation: (s) => openSituation(s), + onPlace: (p) => { + selectTab('globe'); + g.flyTo({ lon: p.lon, lat: p.lat, height: 40e3 }); + }, + onLayer: (id) => { + g.setLayer(id, true); + chrome.refreshDock(); + selectTab('globe'); + toast('Lens on', { icon: icon('layers') }); + }, + }); + const { createCapture } = await import('./ui/capture.js'); + capture = createCapture(views.capture, { + globe: g, + onSealed: () => { + if (current === 'you') you.render(); + }, + onVerify: (b) => inspector.open(b), + }); + + // The back chevron also stops a playing narrative. + $('.world-back').addEventListener( + 'click', + (e) => { + if (narrative.active) { + e.stopImmediatePropagation(); + narrative.stop(); + haptic('tick'); + } + }, + true, + ); + + await feed.load(); + const sits = await getSituations(); + chrome.setPips( + sits.flatMap((s) => + (s.events || []).map((ev) => ({ + at: +new Date(ev.at), + label: ev.label, + color: { + sky: '#7CC4FF', + sea: '#38E1C6', + orbit: '#C9A7FF', + hazards: '#FF6A3D', + eyes: '#FFD166', + ground: '#9BE15D', + weather: '#8AB4F8', + infra: '#B8C0CC', + }[s.family], + })), + ), + ); + refreshAccessory(); + store.on((k) => k === 'followed' && refreshAccessory()); + applyTab(current); + + // Deep link: #s= + const m = /#s=([^&]+)/.exec(location.hash); + if (m) { + const s = situationById(decodeURIComponent(m[1])); + if (s) openSituation(s, feed.cardFor(s.id)); + } + if (g.isStub) + console.info( + '[mobile] running on the offline stub engine', + contractReport, + ); + }) + .catch((err) => { + console.error(err); + toast('The globe could not start on this device'); + }); + +async function openThreadFor(s) { + if (!threads) return; + if (world?.current) await world.close(); + const id = + s.thread || (s.kind === 'reconstruction' ? s.id : threads.startDraft(s)); + selectTab('threads'); + await threads.openById(id); + if (!s.thread && s.kind !== 'reconstruction') + toast('Draft thread started · observation recorded', { + icon: icon('threads'), + }); +} + +applyTab('now'); + +// Keyboard: Escape closes the world. +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && world?.current) world.close(); +}); + +window.__gev = { + app, + get globe() { + return globe; + }, + get world() { + return world; + }, + get chrome() { + return chrome; + }, + get threads() { + return threads; + }, + get capture() { + return capture; + }, + selectTab, + openSituation, + contractReport, +}; diff --git a/mobile/styles/capture.css b/mobile/styles/capture.css new file mode 100644 index 000000000..7bf56f9ba --- /dev/null +++ b/mobile/styles/capture.css @@ -0,0 +1,783 @@ +/* ------------------------------------------------------------------ */ +/* Capture */ +/* ------------------------------------------------------------------ */ + +.capture { + position: absolute; + inset: 0; + z-index: 30; + background: #000; +} + +.cap { + position: absolute; + inset: 0; + overflow: hidden; + background: #000; + -webkit-user-select: none; + user-select: none; +} + +.cap-video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 400ms linear; +} + +.cap.has-camera .cap-video { + opacity: 1; +} + +.cap-ar { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.cap-flash { + position: absolute; + inset: 0; + background: #fff; + pointer-events: none; + z-index: 8; +} + +.cap-top { + position: absolute; + z-index: 3; + top: calc(var(--safe-t) + 8px); + left: calc(12px + var(--safe-l)); + right: calc(12px + var(--safe-r)); + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; +} + +.cap-tape { + position: relative; + width: min(340px, 100%); + height: 46px; + border-radius: 23px; + overflow: hidden; +} + +.cap-tape canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.cap-caret { + position: absolute; + left: 50%; + top: 0; + width: 0; + height: 0; + margin-left: -5px; + border: 5px solid transparent; + border-top: 6px solid var(--eyes); +} + +.cap-caret::after { + content: attr(data-deg); + position: absolute; + top: 26px; + left: 0; + transform: translateX(-50%); + display: none; +} + +.hud { + display: none; + width: 100%; + max-width: 460px; + margin-top: 8px; + padding: 10px 14px 10px; + border-radius: 22px; + font-size: var(--t-cap); +} + +.cap[data-mode='witness'] .hud { + display: block; +} + +.cap[data-mode='witness'] .cap-tape { + height: 36px; +} + +.hud-grid { + display: grid; + grid-template-columns: auto 1fr auto 1fr auto 1fr; + gap: 3px 8px; + align-items: baseline; +} + +.hud-grid span { + font-size: 0.5625rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--ink-3); +} + +.hud-grid b { + font-family: var(--mono); + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.hud-trace { + position: relative; + height: 28px; + margin-top: 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.hud-trace canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.hud-trace span { + position: absolute; + left: 6px; + top: 3px; + padding: 0 4px; + border-radius: 4px; + background: rgba(10, 14, 20, 0.6); + font-size: 0.5625rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--eyes); + opacity: 0.8; +} + +.hud-rec { + display: flex; + align-items: center; + gap: 6px; + margin-top: 7px; + font-size: 0.6875rem; + color: var(--ink-3); +} + +.rec-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ink-4); +} + +.hud-rec.on { + color: var(--ink-2); +} + +.hud-rec.on .rec-dot { + background: var(--seal); + animation: breathe 1.4s ease-in-out infinite; +} + +.cap-level { + position: absolute; + z-index: 2; + left: 50%; + top: 50%; + width: 150px; + height: 0; + margin-left: -75px; + display: none; + pointer-events: none; +} + +.cap[data-mode='witness'] .cap-level { + display: block; +} + +.cap-level i { + position: absolute; + inset: -0.75px 0; + height: 1.5px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.8) 0 38%, + transparent 38% 62%, + rgba(255, 255, 255, 0.8) 62% + ); +} + +.cap-level.level i { + background: linear-gradient( + 90deg, + var(--eyes) 0 38%, + transparent 38% 62%, + var(--eyes) 62% + ); +} + +.cap-status { + position: absolute; + z-index: 6; + left: 50%; + top: calc(var(--safe-t) + 64px); + transform: translateX(-50%); + max-width: calc(100% - 32px); + padding: 10px 16px; + border-radius: 18px; + font-size: var(--t-foot); + font-weight: 600; + text-align: center; +} + +.cap-status[hidden] { + display: none; +} + +.cap-bottom { + position: absolute; + z-index: 3; + left: 0; + right: 0; + bottom: calc(var(--chrome-bottom) - 2px); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + pointer-events: none; +} + +.cap-bottom > * { + pointer-events: auto; +} + +.cap-modes { + display: flex; + gap: 6px; + padding: 4px; + border-radius: var(--r-pill); + background: rgba(0, 0, 0, 0.35); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.cap-modes button { + min-height: 34px; + padding: 0 14px; + border-radius: var(--r-pill); + font-size: var(--t-cap); + font-weight: 750; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.75); + transition: + color var(--dur-1) linear, + background var(--dur-2) linear; +} + +.cap-modes button[aria-selected='true'] { + color: var(--eyes); + background: rgba(255, 209, 102, 0.14); +} + +.cap-controls { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + width: min(420px, 100%); + padding: 0 24px; +} + +.cap-thumb { + justify-self: start; + width: 52px; + height: 52px; + border-radius: 14px; + overflow: hidden; + display: grid; + place-items: center; + color: var(--seal); + font-size: 1.4rem; + background: rgba(0, 0, 0, 0.35); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); + position: relative; +} + +.cap-thumb img, +.cap-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cap-thumb.has::after { + content: ''; + position: absolute; + right: 3px; + bottom: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle, #ffe3a3, #c8913a); + box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.5); +} + +.cap-side { + justify-self: end; +} + +.shutter { + position: relative; + width: 80px; + height: 80px; + border-radius: 50%; + display: grid; + place-items: center; + touch-action: none; +} + +.shutter-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 4px solid #fff; + box-shadow: 0 0 24px rgba(0, 0, 0, 0.35); +} + +.shutter-core { + width: 64px; + height: 64px; + border-radius: 50%; + background: #fff; + transition: + transform var(--dur-2) var(--spring), + border-radius var(--dur-2) var(--ease-out), + background var(--dur-2) linear; +} + +.shutter:active .shutter-core { + transform: scale(0.88); +} + +.cap.recording .shutter-core { + transform: scale(0.5); + border-radius: 10px; + background: var(--live); +} + +.cap.recording .shutter-ring { + border-color: rgba(255, 255, 255, 0.5); + animation: rec-ring 15s linear; +} + +@keyframes rec-ring { + from { + box-shadow: inset 0 0 0 0 var(--live); + } + to { + box-shadow: inset 0 0 0 4px var(--live); + } +} + +.cap[data-mode='look'] .shutter { + visibility: hidden; +} + +.cap-legend { + display: none; + flex-wrap: wrap; + justify-content: center; + gap: 6px 12px; + padding: 0 16px; + font-size: var(--t-cap); + font-weight: 600; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8); +} + +.cap[data-mode='look'] .cap-legend { + display: flex; +} + +.cap-legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.cap-legend i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c); +} + +.cap-legend em { + flex-basis: 100%; + text-align: center; + font-style: normal; + font-weight: 500; + color: var(--ink-3); + font-size: var(--t-cap2); +} + +.cap[data-mode='look'] .cap-controls { + margin-top: -80px; +} + +/* Permission explainer */ +.cap-gate { + position: absolute; + inset: 0; + z-index: 9; + display: flex; + align-items: flex-end; + justify-content: center; + padding: calc(var(--safe-t) + 20px) calc(12px + var(--safe-r)) + var(--chrome-bottom) calc(12px + var(--safe-l)); + background: + radial-gradient( + 90% 60% at 50% 20%, + rgba(255, 209, 102, 0.12), + transparent 70% + ), + rgba(3, 5, 9, 0.86); +} + +.cap-gate[hidden] { + display: none; +} + +.gate-card { + width: min(460px, 100%); + max-height: 100%; + overflow-y: auto; + padding: 24px 20px 16px; + border-radius: 30px; + display: flex; + flex-direction: column; + gap: 4px; + animation: view-in var(--dur-3) var(--spring) both; +} + +.gate-ic { + width: 56px; + height: 56px; + border-radius: 16px; + display: grid; + place-items: center; + font-size: 1.8rem; + color: #1b1300; + background: linear-gradient(160deg, #ffe08f, #e8a93c); + margin-bottom: 10px; +} + +.gate-card h2 { + margin: 0; + font-family: var(--font-display); + font-size: var(--t-title2); + letter-spacing: -0.025em; +} + +.gate-card > p { + margin: 0 0 8px; + color: var(--ink-2); + font-size: var(--t-sub); +} + +.gate-card ul { + list-style: none; + margin: 6px 0 8px; + padding: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.gate-card li { + display: flex; + gap: 14px; + align-items: flex-start; +} + +.gate-card li > .i { + font-size: 1.4rem; + color: var(--eyes); + margin-top: 2px; +} + +.gate-card li div { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--t-foot); + color: var(--ink-3); + line-height: 1.35; +} + +.gate-card li b { + color: var(--ink); + font-size: var(--t-sub); +} + +.gate-card .gate-fine { + display: flex; + gap: 8px; + align-items: flex-start; + font-size: var(--t-cap); + color: var(--ink-3); + margin: 4px 0 10px; +} + +.gate-fine .i { + flex: none; + color: var(--seal); + margin-top: 1px; +} + +.gate-card .pill-btn { + width: 100%; + min-height: 50px; + font-size: var(--t-callout); +} + +.gate-skip { + margin-top: 6px; + background: transparent !important; + color: var(--ink-2); + font-weight: 500 !important; +} + +/* Seal ceremony */ +.cap-seal { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--safe-t) + 12px) calc(14px + var(--safe-r)) + calc(var(--safe-b) + 12px) calc(14px + var(--safe-l)); + background: rgba(3, 5, 8, 0.72); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + animation: view-in 260ms var(--ease-out) both; +} + +.cap-seal[hidden] { + display: none; +} + +.seal-card { + position: relative; + width: min(420px, 100%); + max-height: 100%; + overflow-y: auto; + padding: 14px 16px 16px; + border-radius: 30px; + display: flex; + flex-direction: column; + align-items: center; + scrollbar-width: none; +} + +.seal-media { + position: relative; + width: 100%; + border-radius: 20px; + overflow: hidden; + background: #000; + max-height: 30vh; + aspect-ratio: 4 / 3; +} + +.seal-media img, +.seal-media video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.seal-media .badge-example { + position: absolute; + top: 10px; + left: 10px; +} + +.seal-glyph { + width: 96px; + height: 96px; + margin-top: -48px; + position: relative; + z-index: 2; + filter: drop-shadow(0 6px 18px rgba(232, 183, 92, 0.35)); +} + +.seal-glyph svg { + width: 100%; + height: 100%; +} + +.seal-ring { + fill: rgba(232, 183, 92, 0); + stroke: var(--seal); + stroke-width: 3; + stroke-dasharray: 300; + stroke-dashoffset: 300; + animation: seal-draw 1.6s var(--ease-in-out) infinite alternate; +} + +.seal-check { + fill: none; + stroke: #2a1a00; + stroke-width: 6; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 60; + stroke-dashoffset: 60; +} + +.seal-card.sealed .seal-ring { + animation: + seal-draw 700ms var(--ease-out) forwards, + seal-fill 500ms 500ms var(--ease-out) forwards; +} + +.seal-card.sealed .seal-check { + animation: check-draw 420ms 850ms var(--ease-out) forwards; +} + +.seal-card.sealed .seal-glyph { + animation: seal-pop 700ms 600ms var(--spring) both; +} + +@keyframes seal-draw { + to { + stroke-dashoffset: 0; + } +} + +@keyframes seal-fill { + to { + fill: var(--seal); + stroke: #ffe3a3; + } +} + +@keyframes check-draw { + to { + stroke-dashoffset: 0; + } +} + +@keyframes seal-pop { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.14); + } + 100% { + transform: scale(1); + } +} + +.seal-state { + margin-top: 6px; + font-size: var(--t-sub); + color: var(--ink-2); +} + +.seal-state b { + color: var(--seal-2); + font-weight: 750; +} + +.seal-hash { + margin: 10px 0 4px; + display: grid; + grid-template-columns: repeat(2, auto); + gap: 2px 12px; + font-size: 0.75rem; + color: var(--seal-2); + letter-spacing: 0.02em; + min-height: 2.6em; +} + +.seal-spark { + width: 100%; + margin-top: 10px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--t-cap2); + color: var(--ink-3); + animation: view-in 400ms 900ms var(--ease-out) both; +} + +.seal-spark svg { + width: 100%; + height: 34px; +} + +.seal-spark path { + fill: none; + stroke: var(--eyes); + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} + +.seal-nospark { + height: 22px; + display: grid; + place-items: center; + border: 1px dashed rgba(255, 255, 255, 0.15); + border-radius: 8px; +} + +.seal-facts { + width: 100%; + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 14px; + margin: 12px 0 4px; + font-size: var(--t-foot); + animation: view-in 400ms 1000ms var(--ease-out) both; +} + +.seal-facts dt { + color: var(--ink-3); +} + +.seal-facts dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.seal-facts .mono { + font-size: 0.75rem; +} + +.seal-actions { + display: flex; + gap: 10px; + width: 100%; + margin-top: 12px; + animation: view-in 400ms 1100ms var(--ease-out) both; +} + +.seal-actions .pill-btn { + flex: 1; + min-height: 48px; +} diff --git a/mobile/styles/feed.css b/mobile/styles/feed.css new file mode 100644 index 000000000..03e709172 --- /dev/null +++ b/mobile/styles/feed.css @@ -0,0 +1,467 @@ +/* ------------------------------------------------------------------ */ +/* Now feed */ +/* ------------------------------------------------------------------ */ + +#view-now { + background: + radial-gradient( + 140% 50% at 50% -10%, + rgba(60, 110, 190, 0.16), + rgba(5, 7, 11, 0) 60% + ), + var(--ground); +} + +.feed-inner { + will-change: transform; +} + +.feed-tabs { + position: sticky; + top: calc(var(--safe-t) + 52px); + z-index: 4; + display: flex; + gap: 0; + margin-top: 12px; + padding: 0 calc(6px + var(--safe-r)) 0 calc(6px + var(--safe-l)); + overflow-x: auto; + scrollbar-width: none; + white-space: nowrap; +} + +.feed-tabs::-webkit-scrollbar { + display: none; +} + +.feed-tabs::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: rgba(8, 11, 17, 0.62); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border-bottom: 0.5px solid var(--hairline); + opacity: 0; + transition: opacity var(--dur-1) linear; +} + +#view-now.is-scrolled .feed-tabs::before { + opacity: 1; +} + +#view-now.is-scrolled .navbar::before { + border-bottom-color: transparent; +} + +.feed-tabs button { + position: relative; + flex: none; + height: 44px; + padding: 0 10px; + font-size: var(--t-sub); + font-weight: 600; + color: var(--ink-3); + transition: + color var(--dur-1) linear, + transform var(--dur-1) var(--ease-out); +} + +.feed-tabs button[aria-selected='true'] { + color: var(--ink); + font-weight: 700; +} + +.feed-tabs-ink { + position: absolute; + left: 0; + bottom: 6px; + height: 3px; + border-radius: 2px; + background: var(--ink); + transition: + transform var(--dur-3) var(--spring), + width var(--dur-3) var(--spring); +} + +.feed-note { + padding: 8px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l)); +} + +.near-prompt { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 12px 12px 14px; + border-radius: 20px; + font-size: var(--t-foot); +} + +.near-prompt > .i { + font-size: 1.3rem; + color: var(--tint); +} + +.near-prompt div { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.near-prompt span { + color: var(--ink-3); + font-size: var(--t-cap); + line-height: 1.3; +} + +.near-prompt .pill-btn { + min-height: 36px; + padding: 0 14px; + font-size: var(--t-foot); +} + +.masonry { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 10px calc(7px + var(--safe-r)) 0 calc(7px + var(--safe-l)); +} + +.masonry.swap { + animation: view-in 320ms var(--ease-out) both; +} + +.mcol { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 7px; +} + +.card { + position: relative; + border-radius: 18px; + background: var(--ground-2); + overflow: hidden; + content-visibility: auto; + contain-intrinsic-size: auto 300px; + transition: transform var(--dur-2) var(--spring); + box-shadow: inset 0 0 0 0.5px var(--hairline); +} + +.card:has(.card-hit:active) { + transform: scale(0.965); +} + +.card.is-selected { + box-shadow: + inset 0 0 0 2px var(--fam), + 0 0 0 4px color-mix(in srgb, var(--fam) 25%, transparent); +} + +.card:has(.card-hit:focus-visible) { + outline: 2.5px solid var(--tint); + outline-offset: 2px; +} + +.card-hit { + position: absolute; + inset: 0; + z-index: 3; + border-radius: inherit; +} + +.card-hit:focus-visible { + outline: none; +} + +.card-media { + position: relative; + overflow: hidden; + background: radial-gradient( + 120% 90% at 30% 20%, + color-mix(in srgb, var(--fam, #345) 26%, #0e1520), + #0b1019 + ); +} + +.card-media img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transform: scale(1.03); + transition: + opacity 500ms var(--ease-out), + transform 900ms var(--ease-out); +} + +.card-media img.loaded { + opacity: 1; + transform: none; +} + +.card-media img.failed { + display: none; +} + +.card-media::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + to top, + rgba(4, 6, 10, 0.78) 0%, + rgba(4, 6, 10, 0.25) 32%, + rgba(4, 6, 10, 0) 52% + ); + pointer-events: none; +} + +.card.is-stale .card-media img { + filter: saturate(0.25) brightness(0.8); +} + +.card.is-stale { + box-shadow: inset 0 0 0 1px transparent; + outline: 1.25px dotted var(--ink-4); + outline-offset: -1.25px; +} + +.card-badges { + position: absolute; + z-index: 2; + top: 8px; + left: 8px; + right: 8px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; +} + +.glass-tag { + background: rgba(6, 9, 14, 0.5); + -webkit-backdrop-filter: blur(12px) saturate(1.5); + backdrop-filter: blur(12px) saturate(1.5); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.2); + font-size: 0.625rem; + height: 22px; + min-width: 0; + overflow: hidden; +} + +.live-pill { + display: inline-flex; + align-items: center; + gap: 5px; + height: 20px; + padding: 0 7px; + border-radius: var(--r-pill); + background: rgba(255, 69, 58, 0.92); + color: #fff; + font-size: 0.5625rem; + font-weight: 800; + letter-spacing: 0.08em; + flex: none; +} + +.live-pill .live-dot { + --fam: #fff; + width: 5px; + height: 5px; +} + +.live-pill.recon { + background: rgba(201, 167, 255, 0.92); + color: #1a1030; +} + +.live-pill.recon .i { + font-size: 0.75rem; +} + +.card-metric { + position: absolute; + z-index: 2; + left: 10px; + right: 10px; + bottom: 9px; + display: flex; + align-items: baseline; + flex-wrap: wrap; + column-gap: 3px; + color: #fff; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5); +} + +.card-metric b { + font-family: var(--font-display); + font-size: 1.375rem; + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1; +} + +.card-metric small { + font-size: 0.75rem; + font-weight: 650; + opacity: 0.85; +} + +.card-metric span { + flex-basis: 100%; + font-size: 0.5625rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fam); + margin-top: 3px; +} + +.card-body { + padding: 9px 11px 11px; +} + +.card-title { + margin: 0; + font-size: 0.875rem; + font-weight: 650; + line-height: 1.28; + letter-spacing: -0.012em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-place { + display: flex; + align-items: center; + gap: 3px; + margin-top: 4px; + font-size: 0.6875rem; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; +} + +.card-place span { + overflow: hidden; + text-overflow: ellipsis; +} + +.card-place .i { + flex: none; + font-size: 0.75rem; +} + +.card-foot { + display: flex; + justify-content: space-between; + gap: 6px; + margin-top: 7px; + font-size: 0.625rem; + color: var(--ink-4); +} + +.card-foot .src { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.card-foot .ago { + flex: none; +} + +.card.pulse { + animation: card-pulse 1.6s var(--ease-out); +} + +@keyframes card-pulse { + 0% { + box-shadow: inset 0 0 0 1.5px var(--fam); + } + 100% { + box-shadow: inset 0 0 0 0.5px var(--hairline); + } +} + +/* Skeleton */ +.card.skel .card-media, +.sk-line { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0) 30%, + rgba(255, 255, 255, 0.07) 50%, + rgba(255, 255, 255, 0) 70% + ) + var(--ground-3); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} + +.sk-line { + height: 11px; + border-radius: 6px; + margin: 3px 0 8px; +} + +.sk-line.short { + width: 55%; + margin-bottom: 2px; +} + +.feed-end { + text-align: center; + color: var(--ink-4); + font-size: var(--t-foot); + padding: 26px 0 0; +} + +/* Pull to refresh */ +.ptr { + position: absolute; + z-index: 3; + left: 0; + right: 0; + top: calc(var(--safe-t) + 62px); + height: 40px; + display: grid; + place-items: center; + opacity: 0; + pointer-events: none; + --p: 0; +} + +.ptr-spinner { + position: relative; + width: 30px; + height: 30px; + transform: rotate(calc(var(--p) * 200deg)); +} + +.ptr-spinner i { + position: absolute; + left: 12.5px; + top: 12.5px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--c); + transform: rotate(calc(var(--i) * 45deg)) translateY(-12px); + opacity: clamp(0, calc((var(--p) * 8 - var(--i)) * 1), 1); +} + +.ptr.spinning .ptr-spinner { + animation: spin 900ms linear infinite; +} + +.ptr.spinning .ptr-spinner i { + opacity: 1; +} diff --git a/mobile/styles/globe-ui.css b/mobile/styles/globe-ui.css new file mode 100644 index 000000000..0418cf879 --- /dev/null +++ b/mobile/styles/globe-ui.css @@ -0,0 +1,748 @@ +/* ------------------------------------------------------------------ */ +/* Globe chrome */ +/* ------------------------------------------------------------------ */ + +.gc-top { + position: absolute; + top: calc(var(--safe-t) + 8px); + left: calc(12px + var(--safe-l)); + right: calc(12px + var(--safe-r)); + display: flex; + justify-content: space-between; + align-items: flex-start; + pointer-events: none; + transition: opacity var(--dur-2) linear; +} + +.gc-top > * { + pointer-events: auto; +} + +.gc-readout { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + height: 44px; + padding: 0 14px; + border-radius: 22px; + line-height: 1.15; +} + +.gc-alt { + font-size: var(--t-foot); + font-weight: 700; +} + +.gc-ll { + font-size: 0.625rem; + color: var(--ink-3); +} + +.gc-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.gc-buttons .icon-btn, +.world-back { + position: relative; +} + +.gc-compass .compass-n { + display: grid; + place-items: center; + color: #ff5b4f; + transition: transform 200ms linear; +} + +.gc-attr { + position: absolute; + top: calc(var(--safe-t) + 58px); + left: calc(16px + var(--safe-l)); + font-size: 0.625rem; + color: var(--ink-4); + letter-spacing: 0.01em; + min-height: 24px; + padding: 0 2px; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +.world-back { + position: absolute; + top: calc(var(--safe-t) + 8px); + left: calc(12px + var(--safe-l)); + font-size: 1.2rem; + display: none; +} + +.app.is-world .world-back { + display: grid; + animation: view-in var(--dur-3) var(--spring) both; +} + +.app.is-world .gc-readout, +.app.is-world .gc-attr { + visibility: hidden; +} + +/* Lens popover */ +.lens-pop { + position: absolute; + z-index: 5; + top: calc(var(--safe-t) + 8px); + right: calc(66px + var(--safe-r)); + padding: 6px; + border-radius: 24px; + display: flex; + flex-direction: column; + min-width: 190px; + transform-origin: top right; + animation: pop-in 320ms var(--spring) both; +} + +.lens-pop[hidden] { + display: none; +} + +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.6); + } +} + +.lens-pop button { + display: flex; + align-items: center; + gap: 12px; + height: 46px; + padding: 0 10px; + border-radius: 18px; + font-size: var(--t-sub); + font-weight: 600; + text-align: left; +} + +.lens-pop button[aria-checked='true'] { + background: rgba(255, 255, 255, 0.1); +} + +.lens-sw { + width: 30px; + height: 30px; + border-radius: 50%; + flex: none; + box-shadow: + inset 0 0 0 0.5px rgba(255, 255, 255, 0.35), + 0 2px 6px rgba(0, 0, 0, 0.35); +} + +.lens-pop button[aria-checked='true'] .lens-sw { + box-shadow: + 0 0 0 2px var(--ground), + 0 0 0 4px var(--tint); +} + +/* Bottom stack: lens dock + time ribbon */ +.gc-bottom { + position: absolute; + left: 0; + right: 0; + bottom: var(--chrome-bottom); + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; + transition: + transform var(--dur-3) var(--spring), + opacity var(--dur-2) linear; +} + +.gc-bottom > * { + pointer-events: auto; +} + +.lens-dock { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 2px calc(12px + var(--safe-r)) 2px calc(12px + var(--safe-l)); + scrollbar-width: none; + scroll-padding: 0 12px; +} + +.lens-dock::-webkit-scrollbar { + display: none; +} + +.fam-chip { + position: relative; + flex: none; + height: 38px; + padding: 0 13px 0 10px; + color: var(--ink-2); + background: rgba(14, 18, 26, 0.55); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-edge); + font-size: var(--t-foot); +} + +.fam-chip .i { + font-size: 1.05rem; + color: var(--fam); + opacity: 0.6; +} + +.fam-chip.on { + color: var(--ink); + background: color-mix(in srgb, var(--fam) 24%, rgba(14, 18, 26, 0.6)); + box-shadow: + var(--glass-edge), + inset 0 0 0 1px color-mix(in srgb, var(--fam) 40%, transparent); +} + +.fam-chip.on .i { + opacity: 1; +} + +.fam-count { + font-size: var(--t-cap2); + font-weight: 700; + color: var(--fam); +} + +.fam-count:empty { + display: none; +} + +.lens-dock[data-solo]:not([data-solo='']) .fam-chip:not(.on) { + opacity: 0.45; +} + +/* Time ribbon */ +.ribbon { + position: relative; + display: flex; + align-items: center; + gap: 4px; + height: 56px; + margin: 0 calc(12px + var(--safe-r)) 0 calc(12px + var(--safe-l)); + padding: 0 6px; + border-radius: 28px; + touch-action: none; +} + +.rb-live { + flex: none; + height: 40px; + padding: 0 11px; + border-radius: 20px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.08em; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.08); + transition: + background var(--dur-2) linear, + color var(--dur-2) linear, + transform var(--dur-1) var(--ease-out); +} + +.rb-live .live-dot { + --fam: var(--ink-3); + width: 6px; + height: 6px; +} + +.rb-live .live-dot::after { + animation: none; +} + +.rb-live.is-live { + background: rgba(255, 69, 58, 0.18); + color: #ff6b61; +} + +.rb-live.is-live .live-dot { + --fam: #ff453a; +} + +.rb-live.is-live .live-dot::after { + animation: pulse-ring 1.8s var(--ease-out) infinite; +} + +.rb-live:active { + transform: scale(0.93); +} + +.rb-track { + position: relative; + flex: 1; + min-width: 0; + height: 100%; + cursor: ew-resize; + touch-action: none; +} + +.rb-track canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.rb-head { + position: absolute; + left: 50%; + top: 9px; + bottom: 9px; + width: 2px; + margin-left: -1px; + border-radius: 1px; + background: var(--tint); + box-shadow: 0 0 10px rgba(124, 196, 255, 0.8); +} + +.ribbon.is-live .rb-head { + background: #ff453a; + box-shadow: 0 0 10px rgba(255, 69, 58, 0.8); + animation: breathe 2.6s ease-in-out infinite; +} + +.ribbon.scrubbing .rb-head { + width: 3px; + margin-left: -1.5px; +} + +.rb-read { + flex: none; + width: 84px; + display: flex; + flex-direction: column; + align-items: flex-end; + padding-right: 8px; + line-height: 1.15; +} + +.rb-read b { + font-size: var(--t-foot); + font-weight: 750; + white-space: nowrap; +} + +.rb-read span { + font-size: 0.625rem; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.ribbon.custom .rb-head { + background: var(--eyes); + box-shadow: 0 0 10px rgba(255, 209, 102, 0.7); +} + +/* World mode (an open situation) on compact widths */ +.app.is-world .lens-dock { + display: none; +} + +.app.is-world .gc-bottom { + bottom: calc(var(--sheet-h, 120px) + 10px); +} + +.app.is-world:not([data-sheet='peek']) .gc-bottom { + opacity: 0; + transform: translateY(20px); + pointer-events: none; +} + +.app.is-world:not([data-sheet='peek']) .gc-bottom > * { + pointer-events: none; +} + +.app.is-world .pick-card { + display: none; +} + +/* Pick card */ +.pick-card { + position: absolute; + left: calc(12px + var(--safe-l)); + right: calc(12px + var(--safe-r)); + bottom: calc(var(--chrome-bottom) + 118px); + padding: 12px 12px 12px 14px; + border-radius: 26px; + z-index: 4; +} + +.pick-card[hidden] { + display: none; +} + +.pick-top { + display: flex; + align-items: center; + gap: 12px; +} + +.pick-ic { + width: 40px; + height: 40px; + border-radius: 50%; + display: grid; + place-items: center; + flex: none; + background: color-mix(in srgb, var(--fam) 22%, transparent); + color: var(--fam); + font-size: 1.25rem; +} + +.pick-titles { + flex: 1; + min-width: 0; +} + +.pick-title { + font-family: var(--mono); + font-weight: 700; + font-size: var(--t-headline); + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pick-sub { + font-size: var(--t-foot); + color: var(--ink-3); +} + +.pick-close { + width: 36px; + height: 36px; + font-size: 0.9rem; + color: var(--ink-3); + background: rgba(255, 255, 255, 0.08); +} + +.pick-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 12px 0 2px; +} + +.pick-meta div { + display: flex; + flex-direction: column; + min-width: 0; +} + +.pick-meta span { + font-size: 0.625rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-3); +} + +.pick-meta b { + font-size: var(--t-callout); + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pick-src { + margin-top: 8px; + font-size: 0.6875rem; + color: var(--ink-4); +} + +.pick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.pick-actions .pill-btn { + min-height: 40px; + padding: 0 13px; + font-size: var(--t-foot); + flex: none; +} + +/* Control-Center-style layer grid */ +.grid-head { + display: flex; + align-items: center; + gap: 12px; + padding: 2px 52px 12px 0; +} + +.grid-head > div { + flex: 1; + min-width: 0; +} + +.grid-ic { + width: 46px; + height: 46px; + border-radius: 50%; + display: grid; + place-items: center; + flex: none; + font-size: 1.4rem; + color: #05070b; + background: var(--fam); + box-shadow: 0 0 24px color-mix(in srgb, var(--fam) 45%, transparent); +} + +.tiles { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.tile { + position: relative; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 8px; + min-height: 104px; + padding: 12px; + border-radius: 24px; + text-align: left; + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12); + transition: + transform var(--dur-2) var(--spring), + background var(--dur-2) linear; +} + +.tile-ic { + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 1.1rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.12); + transition: + background var(--dur-2) linear, + color var(--dur-2) linear; +} + +.tile-name { + font-size: var(--t-sub); + font-weight: 650; + line-height: 1.2; + align-self: end; +} + +.tile-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--t-cap); + color: var(--ink-3); +} + +.tile-meta b { + color: var(--ink-2); + font-weight: 700; +} + +.tile-status { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.tile-status i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ink-4); +} + +.tile.on { + background: color-mix(in srgb, var(--fam) 22%, rgba(255, 255, 255, 0.06)); +} + +.tile.on .tile-ic { + background: var(--fam); + color: #05070b; +} + +.tile.on .tile-meta b { + color: var(--fam); +} + +.tile[data-status='live'].on .tile-status i { + background: var(--ok); + box-shadow: 0 0 6px var(--ok); +} + +.tile[data-status='stale'] .tile-status i { + background: transparent; + outline: 1.25px dotted var(--ink-3); +} + +.tile[data-status='error'] .tile-status i { + background: var(--bad); +} + +.tile[data-status='error'] .tile-status { + color: var(--bad); +} + +/* Credits */ +.credit-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.credit { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 0; + border-bottom: 0.5px solid var(--hairline); + font-size: var(--t-foot); +} + +.credit span { + color: var(--ink-2); + line-height: 1.4; +} + +.credit a { + color: var(--tint); + text-decoration: none; + font-size: var(--t-cap); +} + +/* Leading pane Globe view (regular widths) */ +.lens-row { + display: flex; + gap: 10px; + overflow-x: auto; + padding: 0 calc(var(--gutter) + var(--safe-r)) 4px + calc(var(--gutter) + var(--safe-l)); + scrollbar-width: none; +} + +.lens-card { + flex: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 72px; + font-size: var(--t-cap); + font-weight: 600; + color: var(--ink-2); +} + +.lens-card .lens-sw { + width: 56px; + height: 56px; +} + +.lens-card.on { + color: var(--ink); +} + +.lens-card.on .lens-sw { + box-shadow: + 0 0 0 2px var(--ground), + 0 0 0 4px var(--tint); +} + +.lead-fam { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.lead-fam .i { + color: var(--fam); +} + +.lead-tiles { + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); +} + +.lead-tiles .tile { + min-height: 92px; +} + +.link-btn { + color: var(--tint); + font-weight: 600; + min-height: 44px; +} + +/* Hero morph */ +.hero { + position: fixed; + z-index: 80; + overflow: hidden; + pointer-events: none; + contain: strict; +} + +.hero img { + position: absolute; + max-width: none; + transform-origin: center center; + will-change: transform; +} + +.hero-scrim { + position: fixed; + inset: 0; + z-index: 79; + background: rgba(3, 5, 8, 0.55); + pointer-events: none; +} + +.card.hero-source .card-media { + visibility: hidden; +} + +.peek-metric { + flex: none; + font-family: var(--font-display); + font-size: var(--t-title2); + font-weight: 750; + letter-spacing: -0.03em; + color: var(--fam); +} + +.peek-metric small { + font-size: 0.75rem; + font-weight: 650; + color: var(--ink-3); + margin-left: 2px; + letter-spacing: 0; +} + +.brief-sheet .sheet-head { + padding-bottom: 4px; +} diff --git a/mobile/styles/layout.css b/mobile/styles/layout.css new file mode 100644 index 000000000..b4a1d19cc --- /dev/null +++ b/mobile/styles/layout.css @@ -0,0 +1,190 @@ +/* ------------------------------------------------------------------ */ +/* Adaptive layout: phone → two-pane → hinge-aware foldables */ +/* ------------------------------------------------------------------ */ + +.app { + --two-pane: 0; +} + +.search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0 10px; +} + +.search-row .search-field { + flex: 1; +} + +.search-cancel { + min-height: 40px; + padding: 0 10px; + background: transparent !important; + color: var(--tint); + font-weight: 500; +} + +/* Regular width (foldable inner screens, iPhone Fold, tablets). + iPadOS 26-style: the leading pane holds the content column and keeps + the floating tab bar at its foot (thumb reach on a handheld foldable); + the trailing pane is the always-live globe. */ +@media (min-width: 600px) { + .app { + --two-pane: 1; + --lead-w: clamp(360px, 44vw, 420px); + --stage-left: var(--lead-w); + } + + .lead { + right: auto; + width: var(--lead-w); + border-right: 0.5px solid var(--hairline-strong); + visibility: visible !important; + } + + .lead .view { + pointer-events: auto !important; + } + + .stage { + left: var(--stage-left); + } + + #view-globe { + display: block; + } + + .app .globe-chrome { + visibility: visible !important; + } + + .tabbar { + left: calc(12px + var(--safe-l)); + right: auto; + width: calc(var(--lead-w) - 24px); + } + + .app.is-world:not(.is-narrative) .tabbar { + transform: none; + opacity: 1; + } + + .gc-bottom { + bottom: calc(var(--safe-b) + 14px); + } + + .app.is-world .gc-bottom { + bottom: calc(var(--sheet-h, 120px) + 12px); + } + + .pick-card { + left: auto; + width: min(400px, calc(100% - 24px)); + bottom: calc(var(--safe-b) + 140px); + } + + .narr-hud { + left: auto; + width: min(420px, calc(100% - 24px)); + } + + .stage .sheet { + left: 10px; + right: 10px; + max-width: 520px; + } + + /* Modal sheets float centred instead of spanning a wide screen. */ + .sheets .sheet { + left: 0; + right: 0; + margin: 0 auto; + width: min(560px, calc(100% - 24px)); + } + + .tb-accessory { + right: calc(var(--tabbar-h) + 10px); + } + + .capture .cap-bottom, + .capture .cap-top { + left: var(--lead-w); + } + + .hero { + border-radius: 0; + } +} + +/* Vertical hinge (book posture): split exactly at the fold. */ +@media (horizontal-viewport-segments: 2) { + .app { + --lead-w: env(viewport-segment-width 0 0, 50vw); + --stage-left: env(viewport-segment-left 1 0, 50vw); + } + + .lead { + width: var(--lead-w); + } + + .stage { + left: var(--stage-left); + } + + .capture .cap-bottom, + .capture .cap-top { + left: var(--stage-left); + } +} + +/* Tabletop (horizontal fold): globe on top, controls and timeline below. */ +@media (vertical-viewport-segments: 2) { + .app { + --two-pane: 1; + --stage-h: env(viewport-segment-height 0 0, 50vh); + --lead-top: env(viewport-segment-top 0 1, 50vh); + } + + .stage { + left: 0; + right: 0; + top: 0; + bottom: auto; + height: var(--stage-h); + } + + .lead { + top: var(--lead-top); + left: 0; + right: 0; + width: auto; + border-right: 0; + border-top: 0.5px solid var(--hairline-strong); + } + + .lead .view .navbar { + --safe-t: 0px; + } + + .tabbar { + left: max(calc(12px + var(--safe-l)), calc(50% - 300px)); + right: max(calc(12px + var(--safe-r)), calc(50% - 300px)); + width: auto; + } + + .gc-bottom { + bottom: 12px; + } + + .gc-top { + top: 12px; + } +} + +/* Short landscape phones keep a single pane but tighter chrome. */ +@media (max-height: 520px) and (orientation: landscape) and (max-width: 599px) { + .lens-dock { + display: none; + } +} diff --git a/mobile/styles/sheet.css b/mobile/styles/sheet.css new file mode 100644 index 000000000..d0b652452 --- /dev/null +++ b/mobile/styles/sheet.css @@ -0,0 +1,508 @@ +/* ------------------------------------------------------------------ */ +/* Sheets */ +/* ------------------------------------------------------------------ */ + +.sheets { + position: absolute; + inset: 0; + z-index: 60; + pointer-events: none; +} + +.sheet-layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; +} + +.sheet-layer[hidden] { + display: none; +} + +.sheet-scrim { + position: absolute; + inset: 0; + background: #000; + opacity: 0; + pointer-events: auto; +} + +.sheet { + position: absolute; + left: calc(6px + var(--safe-l)); + right: calc(6px + var(--safe-r)); + bottom: 0; + display: flex; + flex-direction: column; + border-radius: 30px 30px 0 0; + background: var(--glass-specular), rgba(16, 20, 28, 0.78); + -webkit-backdrop-filter: blur(30px) saturate(1.7); + backdrop-filter: blur(30px) saturate(1.7); + box-shadow: + inset 0 0.5px 0 rgba(255, 255, 255, 0.3), + inset 0 0 0 0.5px rgba(255, 255, 255, 0.08), + 0 -12px 40px rgba(0, 0, 0, 0.35); + pointer-events: auto; + will-change: transform; + transform: translate3d(0, 110%, 0); + overflow: hidden; + contain: layout paint; +} + +.sheet.opaque { + background: var(--ground-1); +} + +.sheet-grabber { + flex: none; + height: 22px; + display: grid; + place-items: center; + cursor: grab; + touch-action: none; +} + +.sheet-grabber span { + width: 38px; + height: 5px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.3); +} + +.sheet-close { + position: absolute; + z-index: 2; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + font-size: 0.95rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.1); +} + +.sheet-close::before { + /* 44pt hit area around a 32pt glyph. */ + content: ''; + position: absolute; + inset: -6px; +} + +.sheet-head { + flex: none; + padding: 0 20px; + touch-action: none; +} + +.sheet-body { + flex: 1; + min-height: 0; + overflow: hidden; + padding: 0 20px calc(var(--safe-b) + 28px); + overscroll-behavior: contain; +} + +.brief-sheet .sheet-body { + transition: opacity var(--dur-2) linear; +} + +.brief-sheet[data-detent='peek'] .sheet-body { + opacity: 0; + pointer-events: none; +} + +.sheet-body.scrollable { + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.sheet-layer.dragging .sheet-body { + overflow: hidden; +} + +.sheet-title { + font-family: var(--font-display); + font-size: var(--t-title2); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.15; + margin: 2px 0 2px; +} + +.sheet-sub { + font-size: var(--t-foot); + color: var(--ink-3); +} + +/* ------------------------------------------------------------------ */ +/* Situation brief */ +/* ------------------------------------------------------------------ */ + +.brief-top { + display: flex; + align-items: center; + gap: 8px; + height: 22px; + font-size: var(--t-cap); + font-weight: 650; + color: var(--ink-3); + letter-spacing: 0.02em; + padding-right: 40px; +} + +.fam-tag { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 9px 0 7px; + border-radius: var(--r-pill); + color: var(--fam); + background: color-mix(in srgb, var(--fam) 16%, transparent); + font-size: var(--t-cap); + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.fam-tag .i { + font-size: 0.85rem; +} + +.live-tag { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink-2); +} + +.live-tag .live-dot { + --fam: var(--live); + width: 6px; + height: 6px; +} + +.brief-title-row { + display: flex; + align-items: baseline; + gap: 10px; +} + +.brief-title-row .sheet-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 12px 0 4px; +} + +.metric { + min-width: 0; +} + +.metric-v { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.05; + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.metric-v small { + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink-3); + letter-spacing: 0; + margin-left: 2px; +} + +.metric-l { + font-size: var(--t-cap2); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-3); + margin-top: 3px; +} + +.metric.long .metric-v { + font-size: 1.125rem; + line-height: 1.33; +} + +.metric:first-child .metric-v { + color: var(--fam); +} + +.actions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin: 14px 0 6px; +} + +.action { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px 0 9px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.07); + font-size: var(--t-cap2); + font-weight: 600; + color: var(--ink-2); + transition: + transform var(--dur-1) var(--ease-out), + background var(--dur-1) linear; +} + +.action .i { + font-size: 1.35rem; + color: var(--tint); +} + +.action:active { + transform: scale(0.94); +} + +.action[aria-pressed='true'] { + background: color-mix(in srgb, var(--fam, var(--tint)) 20%, transparent); + color: var(--ink); +} + +.action[aria-pressed='true'] .i { + color: var(--fam, var(--tint)); +} + +.action[aria-pressed='true'] .i .f { + fill: currentColor; + stroke: none; +} + +.action .i .f { + fill: none; + stroke: none; +} + +.block { + margin-top: 18px; +} + +.block-h { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: var(--t-foot); + font-weight: 650; + color: var(--ink-2); + margin-bottom: 8px; +} + +.block-h small { + color: var(--ink-4); + font-weight: 500; + font-size: var(--t-cap2); +} + +.spark-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); +} + +.spark-row svg { + color: var(--fam); + flex: 1; + height: 42px; + min-width: 0; +} + +.spark-row .spark-line { + fill: none; + stroke: var(--fam); + stroke-width: 1.8; + stroke-linejoin: round; + stroke-linecap: round; +} + +.spark-row .spark-area { + fill: url(#spark-grad); + opacity: 0.35; +} + +.spark-row .spark-end { + fill: var(--fam); +} + +.spark-label { + flex: none; + text-align: right; + font-size: var(--t-cap); + color: var(--ink-3); + line-height: 1.3; +} + +.spark-label b { + display: block; + font-size: var(--t-headline); + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +.evidence { + display: flex; + gap: 10px; + overflow-x: auto; + scroll-snap-type: x mandatory; + margin: 0 -20px; + padding: 0 20px 4px; + scrollbar-width: none; +} + +.evidence::-webkit-scrollbar { + display: none; +} + +.ev { + position: relative; + flex: none; + color: inherit; + text-decoration: none; + width: 116px; + scroll-snap-align: start; + text-align: left; +} + +.ev-img { + width: 116px; + height: 140px; + border-radius: 14px; + object-fit: cover; + background: var(--ground-3); +} + +.ev-seq { + position: absolute; + top: 6px; + left: 6px; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: rgba(5, 7, 11, 0.7); + font-size: 0.6875rem; + font-weight: 700; + display: grid; + place-items: center; +} + +.ev-t { + margin-top: 6px; + font-size: var(--t-cap); + font-weight: 600; + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ev-s { + font-size: var(--t-cap2); + color: var(--ink-3); + margin-top: 1px; +} + +.facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 14px; + font-size: var(--t-foot); +} + +.facts dt { + color: var(--ink-3); +} + +.facts dd { + margin: 0; + color: var(--ink); + min-width: 0; + overflow-wrap: anywhere; +} + +.metric-table { + width: 100%; + border-collapse: collapse; + font-size: var(--t-foot); +} + +.metric-table td { + padding: 8px 0; + border-bottom: 0.5px solid var(--hairline); + vertical-align: baseline; +} + +.metric-table td:nth-child(2) { + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 650; + padding-left: 8px; +} + +.metric-table td:nth-child(3) { + text-align: right; + color: var(--ink-3); + font-size: var(--t-cap2); + padding-left: 12px; + width: 40%; +} + +.layer-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.layer-chip { + height: 28px; + padding: 0 10px; + font-size: var(--t-cap); + color: var(--fam); + background: color-mix(in srgb, var(--fam) 12%, transparent); +} + +.caveat { + margin-top: 14px; + padding: 12px 14px; + border-radius: 14px; + border: 0.5px dashed rgba(255, 209, 102, 0.45); + color: var(--ink-2); + font-size: var(--t-foot); + line-height: 1.4; +} + +.caveat b { + color: var(--eyes); + font-size: var(--t-cap2); + letter-spacing: 0.06em; + text-transform: uppercase; + display: block; + margin-bottom: 4px; +} diff --git a/mobile/styles/shell.css b/mobile/styles/shell.css new file mode 100644 index 000000000..eb5ec939c --- /dev/null +++ b/mobile/styles/shell.css @@ -0,0 +1,1000 @@ +/* ------------------------------------------------------------------ */ +/* App frame */ +/* ------------------------------------------------------------------ */ + +.app { + position: fixed; + inset: 0; + overflow: hidden; + isolation: isolate; +} + +.stage { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + background: radial-gradient( + 120% 70% at 50% 40%, + #0b1424 0%, + var(--ground) 70% + ) + var(--ground); +} + +.globe-host { + position: absolute; + inset: 0; +} + +.globe-host > * { + position: absolute; + inset: 0; +} + +.globe-veil { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient( + to bottom, + rgba(5, 7, 11, 0.55) 0, + rgba(5, 7, 11, 0) calc(var(--safe-t) + 110px) + ), + linear-gradient(to top, rgba(5, 7, 11, 0.7) 0, rgba(5, 7, 11, 0) 260px); + opacity: 1; + transition: opacity var(--dur-3) var(--ease-out); +} + +.globe-chrome { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} + +.globe-chrome > * { + pointer-events: auto; +} + +.lead { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + +.view { + position: absolute; + inset: 0; + background: var(--ground); + pointer-events: auto; +} + +.view[hidden], +.capture[hidden] { + display: none !important; +} + +.view--scroll { + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: calc(var(--chrome-bottom) + 72px); +} + +.view--scroll::-webkit-scrollbar { + display: none; +} + +/* The Globe tab's leading view only exists on regular widths. */ +#view-globe { + display: none; +} + +/* Phone: the Globe tab and an open situation reveal the stage. */ +.app[data-tab='globe'] .lead, +.app.is-world .lead { + visibility: hidden; +} + +.app[data-tab='globe'] .lead .view, +.app.is-world .lead .view { + pointer-events: none; +} + +.app:not([data-tab='globe']):not(.is-world) .globe-chrome { + visibility: hidden; +} + +.view-enter { + animation: view-in var(--dur-2) var(--ease-out) both; +} + +@keyframes view-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: none; + } +} + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 260ms; + animation-timing-function: var(--ease-out); +} + +::view-transition-old(root) { + animation-name: vt-out; +} + +::view-transition-new(root) { + animation-name: vt-in; +} + +@keyframes vt-out { + to { + opacity: 0; + transform: scale(0.985); + } +} + +@keyframes vt-in { + from { + opacity: 0; + transform: scale(1.012); + } +} + +.tabbar { + view-transition-name: tabbar; +} + +::view-transition-group(tabbar) { + animation-duration: 0s; +} + +/* ------------------------------------------------------------------ */ +/* Navigation bar + large title */ +/* ------------------------------------------------------------------ */ + +.navbar { + position: sticky; + top: 0; + z-index: 5; + height: calc(var(--safe-t) + 52px); + padding: var(--safe-t) calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.navbar::before { + content: ''; + position: absolute; + inset: 0; + background: rgba(8, 11, 17, 0.62); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border-bottom: 0.5px solid var(--hairline); + opacity: 0; + transition: opacity var(--dur-1) linear; +} + +.navbar > * { + position: relative; + pointer-events: auto; +} + +.navbar .nav-title { + font-family: var(--font-display); + font-weight: 650; + font-size: var(--t-headline); + letter-spacing: -0.02em; + opacity: 0; + transform: translateY(6px); + transition: + opacity var(--dur-1) linear, + transform var(--dur-2) var(--ease-out); +} + +.navbar .nav-leading, +.navbar .nav-trailing { + position: absolute; + top: var(--safe-t); + height: 52px; + display: flex; + align-items: center; + gap: 6px; +} + +.navbar .nav-leading { + left: calc(8px + var(--safe-l)); +} + +.navbar .nav-trailing { + right: calc(12px + var(--safe-r)); +} + +.view.is-scrolled .navbar::before { + opacity: 1; +} + +.view.is-scrolled .navbar .nav-title { + opacity: 1; + transform: none; +} + +.large-title { + margin: -6px 0 0; + padding: 0 calc(var(--gutter) + var(--safe-r)) 2px + calc(var(--gutter) + var(--safe-l)); + font-family: var(--font-display); + font-size: var(--t-large); + font-weight: 750; + letter-spacing: -0.035em; + line-height: 1.12; + transform-origin: left center; + will-change: transform; +} + +.title-sub { + padding: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + color: var(--ink-3); + font-size: var(--t-foot); + font-weight: 500; + letter-spacing: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.title-sub .live-dot { + --fam: var(--live); +} + +/* ------------------------------------------------------------------ */ +/* Buttons & controls */ +/* ------------------------------------------------------------------ */ + +.icon-btn { + width: var(--touch); + height: var(--touch); + border-radius: 50%; + display: inline-grid; + place-items: center; + font-size: 1.25rem; + color: var(--ink); + transition: + transform var(--dur-1) var(--ease-out), + background var(--dur-1) linear; +} + +.icon-btn.glass, +.icon-btn.glass-strong { + font-size: 1.15rem; +} + +.icon-btn:active, +.pill-btn:active, +.chip:active, +.tile:active { + transform: scale(0.94); +} + +.pill-btn { + min-height: var(--touch); + padding: 0 18px; + border-radius: var(--r-pill); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: var(--t-sub); + font-weight: 600; + letter-spacing: -0.01em; + transition: transform var(--dur-1) var(--ease-out); + white-space: nowrap; +} + +.pill-btn .i { + font-size: 1.1em; +} + +.pill-btn.primary { + background: var(--ink); + color: #05070b; +} + +.pill-btn.tinted { + background: color-mix(in srgb, var(--fam, var(--tint)) 22%, transparent); + color: var(--fam, var(--tint)); +} + +.pill-btn.plain { + background: rgba(255, 255, 255, 0.08); +} + +.pill-btn.seal { + background: linear-gradient(180deg, #f3cd7f, #c8913a); + color: #2a1a00; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--r-pill); + font-size: var(--t-foot); + font-weight: 600; + white-space: nowrap; + transition: + transform var(--dur-1) var(--ease-out), + background var(--dur-1) linear, + color var(--dur-1) linear; +} + +.segmented { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding: 3px; + border-radius: var(--r-pill); + background: rgba(118, 128, 150, 0.16); + gap: 0; +} + +.segmented button { + position: relative; + z-index: 1; + min-height: 34px; + padding: 0 10px; + font-size: var(--t-foot); + font-weight: 600; + color: var(--ink-2); + border-radius: var(--r-pill); + white-space: nowrap; + transition: color var(--dur-1) linear; +} + +.segmented button[aria-pressed='true'], +.segmented button[aria-selected='true'] { + color: var(--ink); +} + +.segmented .seg-thumb { + position: absolute; + z-index: 0; + top: 3px; + bottom: 3px; + left: 0; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.14); + box-shadow: + inset 0 0.5px 0 rgba(255, 255, 255, 0.3), + 0 3px 10px rgba(0, 0, 0, 0.25); + transition: + transform var(--dur-3) var(--spring), + width var(--dur-3) var(--spring); +} + +/* ------------------------------------------------------------------ */ +/* Tab bar (iOS 26 floating glass) */ +/* ------------------------------------------------------------------ */ + +.tabbar { + position: absolute; + z-index: 40; + left: calc(12px + var(--safe-l)); + right: calc(12px + var(--safe-r)); + bottom: var(--tabbar-gap); + height: var(--tabbar-h); + pointer-events: none; + transition: + transform var(--dur-3) var(--spring-soft), + opacity var(--dur-2) linear; +} + +.app.is-world .tabbar, +.app.hide-tabbar .tabbar { + transform: translateY(calc(100% + var(--tabbar-gap) + 80px)); + opacity: 0; +} + +.tb-full, +.tb-mini { + position: absolute; + inset: 0; + display: flex; + align-items: stretch; + gap: 10px; + transition: + transform var(--dur-3) var(--spring), + opacity var(--dur-2) var(--ease-out); +} + +.tb-full { + pointer-events: auto; +} + +.tb-mini { + pointer-events: none; + opacity: 0; + transform: translateY(8px) scale(1.04); +} + +.tabbar.min .tb-full { + opacity: 0; + transform: translateY(10px) scale(0.92); + pointer-events: none; +} + +.tabbar.min .tb-full .tb-accessory { + transform: translateY(30px); +} + +.tabbar.min .tb-mini { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.tb-tabs, +.tabbar .tb-search, +.tb-accessory, +.tb-mini-tab, +.tb-mini-acc { + background: var(--glass-specular), rgba(16, 20, 28, 0.82); + -webkit-backdrop-filter: blur(26px) saturate(1.8); + backdrop-filter: blur(26px) saturate(1.8); + box-shadow: + inset 0 0.5px 0 rgba(255, 255, 255, 0.34), + inset 0 0 0 0.5px rgba(255, 255, 255, 0.12); +} + +.tb-tabs { + position: relative; + flex: 1; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + border-radius: var(--r-pill); + padding: 4px; + overflow: hidden; +} + +.tb-tabs::after, +.tb-search::after, +.tb-accessory::after, +.tb-mini-tab::after, +.liquid::after { + /* Liquid-glass specular edge. */ + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.16), + rgba(255, 255, 255, 0) 40%, + rgba(255, 255, 255, 0) 70%, + rgba(255, 255, 255, 0.06) + ); + mix-blend-mode: screen; +} + +.tb-lens { + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.13); + box-shadow: + inset 0 0.5px 0 rgba(255, 255, 255, 0.35), + inset 0 -0.5px 0 rgba(255, 255, 255, 0.08); + transition: + transform var(--dur-3) var(--spring), + width var(--dur-3) var(--spring); + pointer-events: none; +} + +.tb-tab { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + color: var(--ink-2); + border-radius: var(--r-pill); + transition: color var(--dur-1) linear; +} + +.tb-tab .i { + font-size: 1.5rem; + transition: transform var(--dur-2) var(--spring); +} + +.tb-tab .i .f { + fill: currentColor; + fill-opacity: 0; + stroke: none; + transition: fill-opacity var(--dur-1) linear; +} + +.tb-tab[aria-selected='true'] { + color: var(--tint); +} + +.tb-tab[aria-selected='true'] .i .f { + fill-opacity: 0.22; +} + +.tb-tab:active .i { + transform: scale(0.86); +} + +.tb-label { + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1; + white-space: nowrap; +} + +.tb-search { + position: relative; + flex: none; + width: var(--tabbar-h); + height: var(--tabbar-h); + border-radius: 50%; + display: grid; + place-items: center; + font-size: 1.4rem; + color: var(--ink); +} + +.tb-accessory { + position: absolute; + left: 0; + right: calc(var(--tabbar-h) + 10px); + bottom: calc(100% + 8px); + height: 48px; + border-radius: var(--r-pill); + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px 0 14px; + text-align: left; + transition: + transform var(--dur-3) var(--spring), + opacity var(--dur-2) linear; +} + +.tb-accessory[hidden] { + display: none; +} + +.acc-glyph { + width: 30px; + height: 30px; + border-radius: 50%; + flex: none; + display: grid; + place-items: center; + color: #05070b; + background: var(--fam); + font-size: 1rem; + position: relative; +} + +.acc-glyph::before { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 1.5px solid var(--fam); + opacity: 0.6; + animation: acc-ring 2.2s var(--ease-out) infinite; +} + +.acc-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.acc-title { + font-size: var(--t-foot); + font-weight: 650; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.acc-meta { + font-size: var(--t-cap2); + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.acc-metric { + font-size: var(--t-foot); + font-weight: 700; + color: var(--fam); + padding-right: 4px; +} + +.tb-mini-tab { + position: relative; + flex: none; + width: var(--tabbar-h); + height: var(--tabbar-h); + border-radius: 50%; + display: grid; + place-items: center; + font-size: 1.5rem; + color: var(--tint); +} + +.tb-mini-tab .i .f { + fill: currentColor; + fill-opacity: 0.22; + stroke: none; +} + +.tb-mini-acc { + position: relative; + flex: 1; + min-width: 0; + height: var(--tabbar-h); + border-radius: var(--r-pill); + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + text-align: left; +} + +.tb-mini-acc[hidden] { + visibility: hidden; +} + +/* ------------------------------------------------------------------ */ +/* Toast */ +/* ------------------------------------------------------------------ */ + +.toast { + position: fixed; + z-index: 100; + left: 50%; + top: calc(var(--safe-t) + 10px); + transform: translate(-50%, -140%); + padding: 10px 16px; + border-radius: var(--r-pill); + display: flex; + gap: 8px; + align-items: center; + font-size: var(--t-foot); + font-weight: 600; + max-width: calc(100vw - 32px); + transition: transform var(--dur-3) var(--spring); + pointer-events: none; +} + +.toast .i { + font-size: 1.1rem; +} + +.toast.show { + transform: translate(-50%, 0); +} + +/* ------------------------------------------------------------------ */ +/* Inset grouped lists (Settings-style) */ +/* ------------------------------------------------------------------ */ + +.section-h { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 22px calc(var(--gutter) + 4px + var(--safe-r)) 8px + calc(var(--gutter) + 4px + var(--safe-l)); + font-size: var(--t-title3); + font-weight: 700; + letter-spacing: -0.02em; +} + +.section-h small { + font-size: var(--t-foot); + font-weight: 500; + color: var(--ink-3); + letter-spacing: 0; +} + +.group { + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + border-radius: var(--r-md); + background: var(--ground-2); + overflow: hidden; +} + +.row { + position: relative; + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + padding: 10px 16px; + width: 100%; + text-align: left; +} + +.row + .row::before { + content: ''; + position: absolute; + top: 0; + left: 58px; + right: 0; + border-top: 0.5px solid var(--hairline); +} + +.row-icon { + width: 30px; + height: 30px; + flex: none; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 1.05rem; + color: #05070b; + background: var(--fam, var(--tint)); +} + +.row-main { + flex: 1; + min-width: 0; +} + +.row-title, +.row-sub { + display: block; +} + +.row-title { + font-size: var(--t-callout); + font-weight: 500; +} + +.row-sub { + font-size: var(--t-foot); + color: var(--ink-3); + margin-top: 1px; +} + +.row-value { + color: var(--ink-3); + font-size: var(--t-callout); +} + +.row .chev { + color: var(--ink-4); + font-size: 0.9rem; +} + +.footnote { + padding: 8px calc(var(--gutter) + 4px + var(--safe-r)) 0 + calc(var(--gutter) + 4px + var(--safe-l)); + font-size: var(--t-foot); + color: var(--ink-3); + line-height: 1.4; +} + +/* iOS switch */ +.switch { + position: relative; + width: 51px; + height: 31px; + flex: none; + border-radius: 16px; + background: rgba(120, 120, 128, 0.32); + transition: background var(--dur-2) linear; +} + +.switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 27px; + height: 27px; + border-radius: 50%; + background: #fff; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); + transition: transform var(--dur-3) var(--spring); +} + +.switch[aria-checked='true'] { + background: var(--fam, var(--ok)); +} + +.switch[aria-checked='true']::after { + transform: translateX(20px); +} + +input[type='range'].slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 28px; + background: transparent; +} + +input[type='range'].slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background: linear-gradient( + 90deg, + var(--tint) var(--p, 50%), + rgba(120, 120, 128, 0.32) var(--p, 50%) + ); +} + +input[type='range'].slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 26px; + height: 26px; + margin-top: -11px; + border-radius: 50%; + background: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +input[type='range'].slider::-moz-range-track { + height: 4px; + border-radius: 2px; + background: rgba(120, 120, 128, 0.32); +} + +input[type='range'].slider::-moz-range-thumb { + width: 26px; + height: 26px; + border: 0; + border-radius: 50%; + background: #fff; +} + +.badge-example { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 5px; + font-size: 0.625rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #1b1300; + background: var(--eyes); + white-space: nowrap; +} + +.badge-sim { + color: var(--ink-2); + background: rgba(255, 255, 255, 0.1); +} + +.noscript { + padding: 40px 20px; + text-align: center; +} + +/* Search */ +.search-field { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + padding: 0 12px; + border-radius: 14px; + background: rgba(118, 128, 150, 0.2); + color: var(--ink-3); + font-size: 1.1rem; +} + +.search-field input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + font-size: var(--t-body); + color: var(--ink); + outline: none; +} + +.search-field input::placeholder { + color: var(--ink-3); +} + +.result { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 4px; + text-align: left; + border-bottom: 0.5px solid var(--hairline); +} + +.result-thumb { + width: 44px; + height: 44px; + border-radius: 10px; + object-fit: cover; + background: var(--ground-3); + flex: none; +} + +.result-icon { + width: 44px; + height: 44px; + border-radius: 10px; + flex: none; + display: grid; + place-items: center; + font-size: 1.2rem; + color: var(--fam, var(--ink-2)); + background: color-mix(in srgb, var(--fam, #fff) 14%, transparent); +} + +.result-title { + font-weight: 600; + font-size: var(--t-callout); +} + +.result-sub { + font-size: var(--t-foot); + color: var(--ink-3); +} + +.results-h { + margin: 18px 4px 4px; + font-size: var(--t-foot); + font-weight: 650; + color: var(--ink-3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +@keyframes acc-ring { + 0% { + transform: scale(0.9); + opacity: 0.7; + } + 100% { + transform: scale(1.35); + opacity: 0; + } +} diff --git a/mobile/styles/threads.css b/mobile/styles/threads.css new file mode 100644 index 000000000..daa4ad80e --- /dev/null +++ b/mobile/styles/threads.css @@ -0,0 +1,1387 @@ +/* ------------------------------------------------------------------ */ +/* Threads */ +/* ------------------------------------------------------------------ */ + +.th-cards { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); +} + +.th-cards .section-h, +.th-cards .footnote { + padding-left: 4px; + padding-right: 4px; +} + +.th-card { + display: block; + width: 100%; + text-align: left; + border-radius: 24px; + overflow: hidden; + background: var(--ground-2); + box-shadow: inset 0 0 0 0.5px var(--hairline); + transition: transform var(--dur-2) var(--spring); +} + +.th-card:active { + transform: scale(0.975); +} + +.th-ba { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 100%; + gap: 2px; + height: 180px; + overflow: hidden; + background: #000; +} + +.th-ba img { + width: 100%; + height: 100%; + min-height: 0; + object-fit: cover; + object-position: 50% 18%; +} + +.th-ba-l, +.th-ba-r { + position: absolute; + bottom: 8px; + padding: 3px 8px; + border-radius: 8px; + background: rgba(5, 7, 11, 0.65); + font-size: var(--t-cap2); + font-weight: 700; + letter-spacing: 0.04em; +} + +.th-ba-l { + left: 8px; +} + +.th-ba-r { + left: calc(50% + 8px); + color: var(--hazards); +} + +.th-card-body { + padding: 12px 16px 14px; + min-width: 0; +} + +.th-kicker { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--t-cap); + color: var(--ink-3); + font-weight: 600; +} + +.th-card h3 { + margin: 6px 0 2px; + font-family: var(--font-display); + font-size: var(--t-title3); + font-weight: 700; + letter-spacing: -0.02em; +} + +.th-place { + font-size: var(--t-foot); + color: var(--ink-3); +} + +.th-stats { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 10px; + font-size: var(--t-foot); + color: var(--ink-2); +} + +.th-stats b { + color: var(--ink); +} + +.th-lic { + margin-top: 8px; + font-size: var(--t-cap2); + color: var(--ink-4); +} + +.th-small { + display: flex; + align-items: center; + gap: 4px; + padding-left: 14px; + padding-right: 14px; +} + +.th-small .th-card-body { + flex: 1; + padding: 12px 8px; +} + +.th-small h3 { + font-size: var(--t-callout); + margin-top: 4px; +} + +.th-small-ic { + width: 44px; + height: 44px; + flex: none; + border-radius: 14px; + display: grid; + place-items: center; + font-size: 1.3rem; + color: var(--fam, var(--orbit)); + background: color-mix(in srgb, var(--fam, var(--orbit)) 15%, transparent); +} + +.th-small .chev { + color: var(--ink-4); + font-size: 0.9rem; +} + +.th-small.example { + opacity: 0.8; + background: transparent; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border: 1px dashed transparent; +} + +/* Detail */ +.th-detail { + min-height: 100%; + background: var(--ground); +} + +.th-detail[hidden], +.th-list[hidden] { + display: none; +} + +.back-btn { + width: auto; + padding: 0 10px 0 2px; + gap: 2px; + display: inline-flex; + align-items: center; + border-radius: 22px; + font-size: var(--t-body); + color: var(--tint); +} + +.back-btn .i { + font-size: 1.3rem; +} + +.view.in-detail .navbar .nav-title { + max-width: calc(100% - 240px); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.th-hero { + padding: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); +} + +.th-ba.big { + height: auto; + aspect-ratio: 16 / 10; + border-radius: 20px; + overflow: hidden; + margin: 4px 0 14px; +} + +.th-ba.big figure { + position: relative; + margin: 0; + min-height: 0; + overflow: hidden; +} + +.th-ba.big figcaption { + position: absolute; + left: 8px; + bottom: 8px; + padding: 3px 8px; + border-radius: 8px; + background: rgba(5, 7, 11, 0.7); + font-size: var(--t-cap2); + font-weight: 650; +} + +.th-title { + margin: 0 0 2px; + font-family: var(--font-display); + font-size: 1.75rem; + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.12; +} + +.strata-nav { + position: sticky; + top: calc(var(--safe-t) + 52px); + z-index: 4; + padding: 10px calc(var(--gutter) + var(--safe-r)) 10px + calc(var(--gutter) + var(--safe-l)); + background: rgba(5, 7, 11, 0.72); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border-bottom: 0.5px solid var(--hairline); + margin-top: 8px; +} + +.strata-nav .segmented button { + font-size: var(--t-cap); + padding: 0 4px; +} + +.stratum { + padding: 14px calc(var(--gutter) + var(--safe-r)) 10px + calc(var(--gutter) + var(--safe-l)); +} + +.stratum-h { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; +} + +.st-n { + width: 28px; + height: 28px; + border-radius: 50%; + flex: none; + display: grid; + place-items: center; + font-size: var(--t-foot); + font-weight: 800; + color: #05070b; + background: var(--ink); +} + +#st-hyp .st-n { + background: var(--orbit); +} + +#st-nar .st-n { + background: var(--eyes); +} + +#st-led .st-n { + background: var(--seal); +} + +.stratum-h h2 { + margin: 0; + font-family: var(--font-display); + font-size: var(--t-title3); + letter-spacing: -0.02em; +} + +.stratum-h p { + margin: 2px 0 0; + font-size: var(--t-foot); + color: var(--ink-3); + line-height: 1.35; +} + +.obs-list, +.narr-steps, +.ledger, +.checks { + list-style: none; + margin: 0; + padding: 0; +} + +.obs-list { + border-radius: 18px; + background: var(--ground-2); + overflow: hidden; +} + +.obs + .obs { + border-top: 0.5px solid var(--hairline); +} + +.obs.flash { + animation: obs-flash 1.4s var(--ease-out); +} + +@keyframes obs-flash { + from { + background: rgba(201, 167, 255, 0.22); + } +} + +.obs-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 9px 14px; + text-align: left; +} + +.obs-id { + width: 32px; + flex: none; + font-size: var(--t-cap); + font-weight: 800; + color: var(--ink-2); + font-variant-numeric: tabular-nums; +} + +.obs-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.obs-main b { + font-size: var(--t-sub); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.obs-main span { + font-size: var(--t-cap); + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.obs-hash { + flex: none; + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.625rem; + color: var(--ink-3); +} + +.obs-hash .i { + font-size: 0.7rem; + color: var(--ink-4); +} + +.obs-more { + padding: 0 14px 14px 58px; + font-size: var(--t-foot); + color: var(--ink-2); +} + +.obs-more p { + margin: 0 0 10px; +} + +.obs-more .facts { + font-size: var(--t-cap); +} + +.obs-more .facts .mono { + font-size: 0.625rem; +} + +.obs-actions { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} + +.obs-actions .pill-btn { + min-height: 38px; + font-size: var(--t-foot); + padding: 0 14px; + text-decoration: none; + color: var(--ink); +} + +.obs-actions .pill-btn.tinted { + --fam: var(--tint); + color: var(--tint); +} + +.example-note { + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 209, 102, 0.08); + border: 0.5px solid rgba(255, 209, 102, 0.3); + font-size: var(--t-cap); + color: var(--ink-2); + line-height: 1.4; +} + +.hyps { + display: flex; + flex-direction: column; + gap: 10px; +} + +.hyp { + padding: 12px 14px; + border-radius: 18px; + background: var(--ground-2); + box-shadow: inset 3px 0 0 var(--orbit); +} + +.hyp.proposed { + background: + repeating-linear-gradient( + 135deg, + rgba(201, 167, 255, 0.05) 0 8px, + transparent 8px 16px + ), + var(--ground-2); + box-shadow: + inset 3px 0 0 var(--orbit), + inset 0 0 0 1px rgba(201, 167, 255, 0.25); +} + +.hyp-top { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + font-size: var(--t-cap); +} + +.hyp-id { + font-weight: 800; + color: var(--orbit); +} + +.hyp-author { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 650; +} + +.hyp-author.ai { + color: var(--orbit); +} + +.hyp-ago { + margin-left: auto; + color: var(--ink-4); +} + +.hyp-text { + margin: 8px 0; + font-size: var(--t-sub); + line-height: 1.4; +} + +.hyp-cites { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.cite { + min-height: 28px; + padding: 0 9px; + border-radius: 8px; + font-size: var(--t-cap); + font-weight: 700; + font-family: var(--mono); + color: var(--ink-2); + background: rgba(255, 255, 255, 0.08); +} + +.cite::before { + content: '↗ '; + color: var(--ink-4); +} + +.hyp-prov { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 10px; + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.25); + font-size: var(--t-cap2); +} + +.hyp-prov dt { + color: var(--ink-4); +} + +.hyp-prov dd { + margin: 0; + color: var(--ink-2); + overflow-wrap: anywhere; +} + +.hyp-foot { + display: flex; + align-items: center; + gap: 14px; + margin-top: 10px; +} + +.conf { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + font-size: var(--t-cap2); + color: var(--ink-3); + min-width: 0; +} + +.conf i { + position: relative; + flex: 1; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; + max-width: 90px; +} + +.conf i::after { + content: ''; + position: absolute; + inset: 0; + width: calc(var(--v) * 100%); + background: var(--orbit); + border-radius: 2px; +} + +.conf b { + color: var(--ink-2); +} + +.votes { + display: flex; + align-items: center; + gap: 4px; +} + +.vote-bar { + width: 40px; + height: 4px; + border-radius: 2px; + background: rgba(255, 106, 61, 0.55); + overflow: hidden; + margin-right: 4px; +} + +.vote-bar i { + display: block; + height: 100%; + background: var(--ok); +} + +.vote { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + min-width: 44px; + padding: 0 8px; + border-radius: 18px; + font-size: var(--t-cap); + font-weight: 700; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.06); +} + +.vote .i { + font-size: 1rem; +} + +.vote.on { + color: var(--ok); + background: rgba(48, 209, 88, 0.15); +} + +.vote.dis.on { + color: var(--hazards); + background: rgba(255, 106, 61, 0.15); +} + +.composer { + margin-top: 14px; + padding: 12px; + border-radius: 18px; + background: + linear-gradient( + 160deg, + rgba(201, 167, 255, 0.12), + rgba(124, 196, 255, 0.05) + ), + var(--ground-2); + box-shadow: inset 0 0 0 0.5px rgba(201, 167, 255, 0.3); +} + +.composer label { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--t-foot); + font-weight: 700; + color: var(--orbit); +} + +.composer textarea { + width: 100%; + margin: 8px 0; + padding: 10px 12px; + border-radius: 12px; + border: 0; + background: rgba(0, 0, 0, 0.3); + font-size: var(--t-sub); + resize: none; + outline: none; + color: var(--ink); +} + +.composer textarea:focus { + box-shadow: 0 0 0 1.5px var(--orbit); +} + +.composer-row { + display: flex; + align-items: center; + gap: 12px; + font-size: var(--t-cap2); + color: var(--ink-3); + line-height: 1.35; +} + +.composer-row .pill-btn { + flex: none; +} + +.narr-card { + display: flex; + align-items: center; + gap: 14px; + padding: 12px; + border-radius: 20px; + background: + linear-gradient(120deg, rgba(255, 209, 102, 0.14), transparent), + var(--ground-2); +} + +.narr-card div { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--t-foot); + color: var(--ink-3); +} + +.narr-card b { + color: var(--ink); + font-size: var(--t-sub); +} + +.narr-play { + width: 56px; + height: 56px; + flex: none; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 1.5rem; + color: #1b1300; + background: var(--eyes); + box-shadow: 0 8px 24px rgba(255, 209, 102, 0.3); + transition: transform var(--dur-2) var(--spring); +} + +.narr-play:active { + transform: scale(0.9); +} + +.narr-steps { + margin-top: 10px; + columns: 2; + column-gap: 16px; + font-size: var(--t-cap); +} + +.narr-steps li { + display: grid; + grid-template-columns: 22px 1fr; + column-gap: 6px; + break-inside: avoid; + padding: 5px 0; + border-bottom: 0.5px solid var(--hairline); +} + +.narr-steps span { + color: var(--eyes); + font-weight: 700; +} + +.narr-steps b { + font-weight: 600; +} + +.narr-steps em { + grid-column: 2; + font-style: normal; + color: var(--ink-4); + font-size: var(--t-cap2); +} + +.ledger-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.ledger-actions .pill-btn { + min-height: 38px; + font-size: var(--t-foot); + padding: 0 14px; +} + +.led-status { + font-size: var(--t-cap); + font-weight: 650; +} + +.led-status.ok { + color: var(--ok); +} + +.led-status.bad { + color: var(--bad); +} + +.ledger { + position: relative; + border-radius: 18px; + background: var(--ground-1); + box-shadow: inset 0 0 0 0.5px var(--hairline); + padding: 4px 0; +} + +.ledger::before { + content: ''; + position: absolute; + left: 27px; + top: 20px; + bottom: 20px; + border-left: 1px dashed rgba(232, 183, 92, 0.35); +} + +.led-row { + position: relative; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 46px; + padding: 6px 12px; + text-align: left; +} + +.led-seq { + position: relative; + z-index: 1; + width: 30px; + height: 22px; + flex: none; + border-radius: 11px; + display: grid; + place-items: center; + font-size: 0.625rem; + font-weight: 800; + background: var(--ground-3); + color: var(--ink-2); +} + +.led.observation .led-seq { + background: #2a3240; +} + +.led.hypothesis .led-seq { + background: rgba(201, 167, 255, 0.3); +} + +.led.ai .led-seq { + background: var(--orbit); + color: #160b2a; +} + +.led.vote .led-seq { + background: rgba(48, 209, 88, 0.3); +} + +.led.thread .led-seq { + background: var(--seal); + color: #2a1a00; +} + +.led-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + font-size: var(--t-cap); +} + +.led-main b { + font-family: var(--mono); + font-weight: 600; + font-size: 0.6875rem; +} + +.led-main span { + color: var(--ink-3); + font-size: var(--t-cap2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.led-hash { + flex: none; + font-size: 0.625rem; + color: var(--seal-2); +} + +.led-json { + margin: 0 12px 10px 54px; + padding: 10px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + font-size: 0.625rem; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--ink-2); + max-height: 260px; + overflow: auto; +} + +/* Narrative player (in the world) */ +.narr-hud { + position: absolute; + z-index: 5; + left: calc(12px + var(--safe-l)); + right: calc(12px + var(--safe-r)); + bottom: calc(var(--safe-b) + 84px); + padding: 14px 14px 12px; + border-radius: 26px; + display: none; +} + +.app.is-narrative .narr-hud { + display: block; + animation: view-in var(--dur-3) var(--spring) both; +} + +.app.is-world.is-narrative .gc-bottom { + bottom: calc(var(--safe-b) + 12px); + opacity: 1; + transform: none; + pointer-events: auto; +} + +.app.is-world.is-narrative .gc-bottom > * { + pointer-events: auto; +} + +.narr-top { + display: flex; + align-items: center; + gap: 10px; +} + +.narr-top .narr-play { + width: 44px; + height: 44px; + font-size: 1.15rem; +} + +.narr-titles { + flex: 1; + min-width: 0; +} + +.narr-step { + font-size: var(--t-cap2); + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--eyes); +} + +.narr-name { + font-family: var(--font-display); + font-size: var(--t-headline); + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.narr-prog { + display: flex; + gap: 3px; + margin-top: 12px; +} + +.narr-prog i { + flex: 1; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.15); + transition: background var(--dur-2) linear; +} + +.narr-prog i.done { + background: var(--eyes); +} + +.narr-cav { + margin-top: 8px; + font-size: 0.625rem; + font-weight: 650; + letter-spacing: 0.04em; + color: var(--ink-3); +} + +/* ------------------------------------------------------------------ */ +/* You */ +/* ------------------------------------------------------------------ */ + +.you-id { + display: flex; + align-items: center; + gap: 14px; + padding: 8px calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); +} + +.avatar { + width: 60px; + height: 60px; + border-radius: 50%; + flex: none; + display: grid; + place-items: center; + font-size: 1.6rem; + color: #05070b; + background: conic-gradient( + from 200deg, + var(--sky), + var(--orbit), + var(--hazards), + var(--eyes), + var(--ground-sig), + var(--sea), + var(--sky) + ); +} + +.you-id b { + font-size: var(--t-headline); +} + +.you-id div > span { + display: block; + font-size: var(--t-cap); + color: var(--ink-3); +} + +.captures { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + border-radius: 18px; + overflow: hidden; +} + +.capture-tile { + position: relative; + aspect-ratio: 1; + background: var(--ground-3); + overflow: hidden; +} + +.capture-tile img, +.capture-tile video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.capture-tile .ct-seal { + position: absolute; + right: 5px; + bottom: 5px; + font-size: 1.15rem; + color: var(--seal); + --seal-ink: #2a1a00; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6)); +} + +.capture-tile .ct-n { + position: absolute; + left: 6px; + top: 6px; + font-size: 0.625rem; + font-weight: 800; + padding: 2px 6px; + border-radius: 6px; + background: rgba(5, 7, 11, 0.6); +} + +.empty { + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + padding: 22px 18px; + border-radius: 18px; + border: 1px dashed rgba(255, 255, 255, 0.12); + text-align: center; + color: var(--ink-3); + font-size: var(--t-foot); +} + +.empty .i { + font-size: 1.8rem; + color: var(--seal); + margin-bottom: 6px; +} + +.holo { + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + padding: 16px; + border-radius: 18px; + display: flex; + gap: 14px; + align-items: center; + background: + radial-gradient( + 80% 120% at 0% 0%, + rgba(56, 225, 198, 0.18), + transparent 60% + ), + var(--ground-2); + font-size: var(--t-foot); + color: var(--ink-3); +} + +.holo .i { + font-size: 2rem; + color: var(--sea); + flex: none; +} + +.holo b { + display: block; + color: var(--ink); + font-size: var(--t-sub); +} + +.seg-row { + padding: 8px 16px 12px; +} + +.slider-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 16px 14px; +} + +/* Inspector */ +.inspector .sheet-body { + padding-top: 4px; +} + +.insp-head { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 48px 12px 0; +} + +.insp-thumb { + position: relative; + width: 64px; + height: 64px; + flex: none; + border-radius: 14px; + overflow: hidden; + background: var(--ground-3); +} + +.insp-thumb img, +.insp-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.insp-thumb span { + position: absolute; + right: 3px; + bottom: 3px; + font-size: 1.2rem; + color: var(--seal); + --seal-ink: #2a1a00; +} + +.insp-banner { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px; + border-radius: 18px; + font-size: var(--t-cap); + color: var(--ink-3); + line-height: 1.4; +} + +.insp-banner > .i { + font-size: 1.8rem; + flex: none; +} + +.insp-banner b { + display: block; + font-size: var(--t-headline); + color: var(--ink); + margin-bottom: 2px; +} + +.insp-banner.pass { + background: linear-gradient( + 135deg, + rgba(232, 183, 92, 0.2), + rgba(232, 183, 92, 0.04) + ); +} + +.insp-banner.pass > .i { + color: var(--seal); +} + +.insp-banner.warn { + background: rgba(255, 214, 10, 0.1); +} + +.insp-banner.warn > .i { + color: var(--seal); +} + +.insp-banner.fail { + background: rgba(255, 69, 58, 0.14); +} + +.insp-banner.fail > .i { + color: var(--bad); +} + +.insp-banner.running { + align-items: center; + background: var(--ground-2); +} + +.spinner { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2.5px solid rgba(255, 255, 255, 0.15); + border-top-color: var(--seal); + animation: spin 800ms linear infinite; +} + +.checks { + margin-top: 14px; +} + +.check { + display: flex; + gap: 12px; + padding: 10px 0; + border-bottom: 0.5px solid var(--hairline); +} + +.check .ck { + width: 24px; + height: 24px; + border-radius: 50%; + flex: none; + display: grid; + place-items: center; + font-size: 0.8rem; + color: #05070b; +} + +.check.pass .ck { + background: var(--ok); +} + +.check.warn .ck { + background: var(--warn); +} + +.check.fail .ck { + background: var(--bad); + color: #fff; +} + +.check.na .ck { + background: var(--ink-4); +} + +.check div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.check b { + font-size: var(--t-sub); + font-weight: 650; +} + +.check span { + font-size: var(--t-cap); + color: var(--ink-3); + line-height: 1.35; + font-variant-numeric: tabular-nums; +} + +.smalls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 16px; +} + +.sm { + display: grid; + grid-template-columns: 22px 1fr; + grid-template-rows: auto auto; + align-items: center; + column-gap: 6px; +} + +.sm span { + grid-row: span 2; + font-family: var(--mono); + font-size: var(--t-foot); + color: var(--ink-2); +} + +.sm svg { + width: 100%; + height: 26px; +} + +.sm path { + fill: none; + stroke: var(--eyes); + stroke-width: 1.2; + vector-effect: non-scaling-stroke; +} + +.sm em { + font-style: normal; + font-size: 0.625rem; + color: var(--ink-4); +} + +.insp-map { + position: relative; + border-radius: 14px; + overflow: hidden; + aspect-ratio: 2 / 1; + background: var(--ground-3); +} + +.insp-map img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.insp-map i { + position: absolute; + left: 50%; + top: 50%; + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: var(--seal); + box-shadow: + 0 0 0 3px rgba(5, 7, 11, 0.7), + 0 0 0 14px rgba(232, 183, 92, 0.2); +} + +.insp-map span { + position: absolute; + left: 8px; + bottom: 8px; + padding: 3px 8px; + border-radius: 8px; + background: rgba(5, 7, 11, 0.7); + font-size: 0.625rem; +} + +.mono-facts { + font-size: var(--t-cap); +} + +.mono-facts dt { + font-family: var(--mono); +} + +.mono-facts .mono { + font-size: 0.625rem; + color: var(--ink-2); +} + +.insp-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.insp-actions .pill-btn { + flex: 1; + font-size: var(--t-foot); +} diff --git a/mobile/styles/tokens.css b/mobile/styles/tokens.css new file mode 100644 index 000000000..2f4c3f817 --- /dev/null +++ b/mobile/styles/tokens.css @@ -0,0 +1,365 @@ +/* + * God's Eye — mobile design tokens. + * One ground, one light source (the globe), glass over it. + * Signal colours are reserved for meaning (DESIGN.md §4). + */ + +:root { + color-scheme: dark; + + /* Ground */ + --ground: #05070b; + --ground-1: #0a0e15; + --ground-2: #10151e; + --ground-3: #171d28; + --hairline: rgba(255, 255, 255, 0.08); + --hairline-strong: rgba(255, 255, 255, 0.14); + + /* Ink */ + --ink: #f4f7fb; + --ink-2: rgba(235, 241, 250, 0.72); + --ink-3: rgba(235, 241, 250, 0.5); + --ink-4: rgba(235, 241, 250, 0.3); + + /* Signals (reserved) */ + --sky: #7cc4ff; + --sea: #38e1c6; + --orbit: #c9a7ff; + --hazards: #ff6a3d; + --eyes: #ffd166; + --ground-sig: #9be15d; + --weather: #8ab4f8; + --infra: #b8c0cc; + --seal: #e8b75c; + --seal-2: #ffdf9e; + --live: #ff453a; + --ok: #30d158; + --warn: #ffd60a; + --bad: #ff453a; + --tint: #7cc4ff; + + /* Glass */ + --glass-bg: rgba(22, 27, 37, 0.52); + --glass-bg-strong: rgba(18, 22, 31, 0.74); + --glass-blur: blur(24px) saturate(1.6); + --glass-edge: + inset 0 0.5px 0 0 rgba(255, 255, 255, 0.32), + inset 0 0 0 0.5px rgba(255, 255, 255, 0.1); + --glass-specular: radial-gradient( + 120% 80% at 30% -20%, + rgba(255, 255, 255, 0.18), + rgba(255, 255, 255, 0) 60% + ); + + /* Geometry */ + --r-xs: 8px; + --r-sm: 12px; + --r-md: 18px; + --r-lg: 24px; + --r-xl: 28px; + --r-pill: 999px; + --touch: 44px; + --gutter: 16px; + + /* Safe areas */ + --safe-t: env(safe-area-inset-top, 0px); + --safe-b: env(safe-area-inset-bottom, 0px); + --safe-l: env(safe-area-inset-left, 0px); + --safe-r: env(safe-area-inset-right, 0px); + --tabbar-h: 62px; + --tabbar-gap: max(10px, calc(var(--safe-b) - 8px)); + --chrome-bottom: calc(var(--tabbar-h) + var(--tabbar-gap) + 10px); + + /* Type (rem-based so Dynamic Type / browser zoom scales it) */ + --font: + system-ui, -apple-system, 'SF Pro Text', 'Roboto Flex', Roboto, 'Segoe UI', + sans-serif; + --font-display: + system-ui, -apple-system, 'SF Pro Display', 'Roboto Flex', Roboto, + 'Segoe UI', sans-serif; + --font-rounded: ui-rounded, 'SF Pro Rounded', system-ui, sans-serif; + --mono: + ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, 'Roboto Mono', monospace; + --t-large: 2.125rem; /* 34 pt */ + --t-title2: 1.375rem; + --t-title3: 1.25rem; + --t-headline: 1.0625rem; + --t-body: 1.0625rem; + --t-callout: 1rem; + --t-sub: 0.9375rem; + --t-foot: 0.8125rem; + --t-cap: 0.75rem; + --t-cap2: 0.6875rem; + + /* Motion. Springs are sampled with linear() where supported. */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --spring: cubic-bezier(0.32, 1.25, 0.4, 1); + --spring-soft: cubic-bezier(0.25, 1.1, 0.4, 1); + --dur-1: 160ms; + --dur-2: 280ms; + --dur-3: 460ms; + + /* Layout */ + --lead-w: 100vw; +} + +@supports (transition-timing-function: linear(0, 1)) { + :root { + /* Critically-damped-ish spring (response 0.42, damping 0.82). */ + --spring: linear( + 0, + 0.009, + 0.035 2.1%, + 0.141 4.4%, + 0.723 12.9%, + 0.938 16.7%, + 1.017 19.8%, + 1.067, + 1.089 25.9%, + 1.085 29.1%, + 1.05 35.4%, + 1.016 42.4%, + 0.999 51.8%, + 0.996 58.3%, + 1 + ); + --spring-soft: linear( + 0, + 0.06 3%, + 0.25 7.5%, + 0.6 15%, + 0.84 22%, + 0.96 30%, + 1.012 38%, + 1.02 46%, + 1.006 60%, + 1 + ); + } +} + +@media (prefers-reduced-motion: reduce) { + :root { + --dur-1: 1ms; + --dur-2: 120ms; + --dur-3: 160ms; + } +} + +:root[data-reduce-motion='on'] { + --dur-1: 1ms; + --dur-2: 120ms; + --dur-3: 160ms; +} + +/* Base */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + background: var(--ground); + color: var(--ink); + font-family: var(--font); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; + height: 100%; + overscroll-behavior: none; +} + +body { + margin: 0; + min-height: 100%; + height: 100%; + overflow: hidden; + background: var(--ground); + font-size: var(--t-body); + line-height: 1.32; + letter-spacing: -0.012em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'kern', 'ss01'; + overscroll-behavior: none; + touch-action: manipulation; +} + +button { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + margin: 0; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + touch-action: manipulation; +} + +input, +textarea, +select { + font: inherit; + color: inherit; +} + +img { + display: block; + max-width: 100%; +} + +svg.i { + width: 1em; + height: 1em; + display: inline-block; + flex: none; + overflow: visible; +} + +:focus { + outline: none; +} + +:focus-visible { + outline: 2.5px solid var(--tint); + outline-offset: 2px; + border-radius: 10px; +} + +.tnum, +.num { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; +} + +.mono { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} + +.sr-only { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Glass primitive */ +.glass { + background: var(--glass-specular), var(--glass-bg); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-edge); +} + +.glass-strong { + background: var(--glass-specular), var(--glass-bg-strong); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-edge); +} + +/* Family colour binding */ +[data-family='sky'] { + --fam: var(--sky); +} +[data-family='sea'] { + --fam: var(--sea); +} +[data-family='orbit'] { + --fam: var(--orbit); +} +[data-family='hazards'] { + --fam: var(--hazards); +} +[data-family='eyes'] { + --fam: var(--eyes); +} +[data-family='ground'] { + --fam: var(--ground-sig); +} +[data-family='weather'] { + --fam: var(--weather); +} +[data-family='infra'] { + --fam: var(--infra); +} + +@keyframes pulse-ring { + 0% { + transform: scale(0.6); + opacity: 0.85; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +} + +@keyframes shimmer { + 0% { + background-position: -180% 0; + } + 100% { + background-position: 180% 0; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes breathe { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} + +.live-dot { + position: relative; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--fam, var(--live)); + flex: none; +} + +.live-dot::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: inherit; + animation: pulse-ring 1.8s var(--ease-out) infinite; +} + +.live-dot.stale { + background: transparent; + outline: 1.25px dotted var(--ink-3); +} + +.live-dot.stale::after { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .live-dot::after { + animation: none; + } +} diff --git a/mobile/ui/capture.js b/mobile/ui/capture.js new file mode 100644 index 000000000..304c0ef3c --- /dev/null +++ b/mobile/ui/capture.js @@ -0,0 +1,957 @@ +/** + * Capture tab: one dial, two modes. + * Look — the God's-eye view projected around you (aircraft, satellites, + * ships) on the camera, aligned by compass + gyro. Drag to look on + * devices without motion sensors. + * Witness — signed photo/video with IMU, GPS, heading and a hash chain. + */ + +import { icon } from './icons.js'; +import { + $, + h, + esc, + clamp, + haptic, + toast, + fmtDist, + fmtAlt, + sparkPath, + reducedMotion, +} from './dom.js'; +import { createCamera } from '../capture/camera.js'; +import { + createSensors, + requestMotionPermission, + poseFromAzEl, + lookAngles, + projectDir, + dirFromAzEl, +} from '../capture/sensors.js'; +import { seal, networkTimeOffset } from '../capture/provenance.js'; + +const KIND_COLOR = { + aircraft: '#7CC4FF', + helicopter: '#7CC4FF', + military: '#7CC4FF', + ship: '#38E1C6', + vessel: '#38E1C6', + satellite: '#C9A7FF', + station: '#FFFFFF', + quake: '#FF6A3D', + fire: '#FF6A3D', +}; +const KIND_LABEL = { + aircraft: ['aircraft', 'aircraft'], + military: ['military', 'military'], + helicopter: ['helicopter', 'helicopters'], + ship: ['ship', 'ships'], + vessel: ['ship', 'ships'], + satellite: ['satellite', 'satellites'], + station: ['space station', 'space stations'], +}; +const FALLBACK_POS = { lat: 37.7749, lon: -122.4194, alt: 16, simulated: true }; +const GATE_KEY = 'gev.mobile.capture.gate'; + +export function createCapture(view, { globe, onSealed, onVerify }) { + view.innerHTML = ` +
+ + + +
+
+
+
+ LAT—LON— + ±—HDG— + PITCH—ROLL— +
+
IMU |a|
+
Session idle
+
+
+ +
+
+ + +
+
+ + + +
+
+
+ + +
`; + + const root = $('.cap', view); + const video = $('.cap-video', view); + const ar = $('.cap-ar', view); + const actx = ar.getContext('2d'); + const tape = $('.cap-tape canvas', view); + const tctx = tape.getContext('2d'); + const traceC = $('.hud-trace canvas', view); + const trctx = traceC.getContext('2d'); + const gate = $('.cap-gate', view); + const sealEl = $('.cap-seal', view); + const status = $('.cap-status', view); + const shutter = $('.shutter', view); + const level = $('.cap-level i', view); + const camera = createCamera(video); + const sensors = createSensors(); + + let mode = 'look'; + let active = false; + let started = false; + let camOk = false; + let raf = 0; + let sim = { az: 250, el: 14 }; + let contacts = []; + let contactsAt = 0; + let W = 0; + let H = 0; + let dpr = 1; + let lastCapture = null; + let netOffset = null; + const VFOV = 62; + + /* ---------- lifecycle ---------- */ + function resize() { + const r = root.getBoundingClientRect(); + dpr = Math.min(2, devicePixelRatio || 1); + W = r.width; + H = r.height; + ar.width = Math.round(W * dpr); + ar.height = Math.round(H * dpr); + const tr = tape.getBoundingClientRect(); + tape.width = Math.round(tr.width * dpr); + tape.height = Math.round(tr.height * dpr); + const hr = traceC.getBoundingClientRect(); + traceC.width = Math.round(hr.width * dpr); + traceC.height = Math.round(hr.height * dpr); + } + new ResizeObserver(resize).observe(root); + + async function begin({ withSensors }) { + gate.hidden = true; + try { + sessionStorage.setItem(GATE_KEY, '1'); + } catch { + /* ignore */ + } + started = true; + if (withSensors) { + const perm = await requestMotionPermission(); + if (perm === 'denied') + showStatus('Motion access denied — drag to look around', 4000); + sensors.start(); + try { + await camera.start(); + camOk = true; + root.classList.add('has-camera'); + } catch (err) { + camOk = false; + root.classList.remove('has-camera'); + showStatus( + err?.name === 'NotAllowedError' + ? 'Camera access was declined — showing a simulated sky' + : 'No camera here — showing a simulated sky', + 4500, + ); + } + } + networkTimeOffset().then((o) => (netOffset = o)); + if (mode === 'witness') sensors.beginSession(); + loop(); + } + + function enter() { + active = true; + resize(); + let seen = false; + try { + seen = sessionStorage.getItem(GATE_KEY) === '1'; + } catch { + /* ignore */ + } + if (!started && !seen) gate.hidden = false; + else if (!started) begin({ withSensors: true }); + else { + if (camOk) camera.start().catch(() => {}); + sensors.start(); + if (mode === 'witness' && !sensors.recording) sensors.beginSession(); + loop(); + } + } + + function leave() { + active = false; + cancelAnimationFrame(raf); + raf = 0; + camera.stop(); + sensors.endSession(); + sensors.stop(); + } + + $('.gate-go', view).addEventListener('click', () => + begin({ withSensors: true }), + ); + $('.gate-skip', view).addEventListener('click', () => + begin({ withSensors: false }), + ); + + function showStatus(text, ms = 2500) { + status.textContent = text; + status.hidden = false; + clearTimeout(showStatus.t); + showStatus.t = setTimeout(() => (status.hidden = true), ms); + } + + /* ---------- mode dial ---------- */ + function setMode(m) { + if (m === mode) return; + mode = m; + root.dataset.mode = m; + view + .querySelectorAll('.cap-modes [data-mode]') + .forEach((b) => + b.setAttribute('aria-selected', String(b.dataset.mode === m)), + ); + haptic('tick'); + if (m === 'witness' && started) sensors.beginSession(); + if (m === 'look') sensors.endSession(); + } + $('.cap-modes', view).addEventListener('click', (e) => { + const b = e.target.closest('[data-mode]'); + if (b) setMode(b.dataset.mode); + }); + // Swipe the viewfinder sideways to change mode, like Camera. + let swipe = null; + root.addEventListener( + 'touchstart', + (e) => + (swipe = e.target.closest('.cap-bottom, .cap-seal, .cap-gate') + ? null + : { x: e.touches[0].clientX, y: e.touches[0].clientY }), + { passive: true }, + ); + root.addEventListener( + 'touchend', + (e) => { + if (!swipe || mode === 'look') return (swipe = null); + const t = e.changedTouches[0]; + const dx = t.clientX - swipe.x; + if (Math.abs(dx) > 70 && Math.abs(t.clientY - swipe.y) < 50) + setMode(dx > 0 ? 'look' : 'witness'); + swipe = null; + }, + { passive: true }, + ); + + /* ---------- drag to look (no sensors) ---------- */ + let look = null; + ar.addEventListener('pointerdown', (e) => { + if (sensors.state.hasOrientation) return; + look = { x: e.clientX, y: e.clientY, az: sim.az, el: sim.el }; + ar.setPointerCapture(e.pointerId); + }); + ar.addEventListener('pointermove', (e) => { + if (!look) return; + const degPerPx = VFOV / H; + sim.az = (look.az - (e.clientX - look.x) * degPerPx + 360) % 360; + sim.el = clamp(look.el + (e.clientY - look.y) * degPerPx, -30, 85); + }); + ar.addEventListener('pointerup', () => (look = null)); + ar.addEventListener('pointercancel', () => (look = null)); + ar.style.touchAction = 'none'; + + function observer() { + const f = sensors.state.fix; + return f + ? { lat: f.lat, lon: f.lon, alt: f.alt || 0, acc: f.acc } + : FALLBACK_POS; + } + + function pose() { + return sensors.pose() || poseFromAzEl(sim.az, sim.el); + } + + /* ---------- render loop ---------- */ + function loop() { + if (!active || raf) return; + const frame = () => { + raf = 0; + if (!active) return; + const p = pose(); + if (mode === 'look') drawLook(p); + else drawWitness(p); + drawTape(p); + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + } + + const stars = (() => { + let s = 11; + const r = () => (s = (s * 16807) % 2147483647) / 2147483647; + return Array.from({ length: 260 }, () => ({ + az: r() * 360, + el: Math.asin(r()) * 57.3, + m: r(), + })); + })(); + + function drawSky(p) { + // Horizon position at the screen centre column. + const hz = projectDir(dirFromAzEl(p.az, 0), p, W, H, VFOV); + const hy = hz.behind ? (p.el > 0 ? H * 2 : -H) : hz.y; + const g = actx.createLinearGradient(0, hy - H, 0, hy); + g.addColorStop(0, '#03060f'); + g.addColorStop(0.55, '#0a1630'); + g.addColorStop(0.9, '#1b3557'); + g.addColorStop(1, '#3b5f80'); + actx.fillStyle = g; + actx.fillRect(0, 0, W, H); + actx.fillStyle = '#05080d'; + actx.save(); + // Ground polygon below the horizon line, honouring roll. + const l = projectDir(dirFromAzEl(p.az - 80, 0), p, W, H, VFOV); + const r = projectDir(dirFromAzEl(p.az + 80, 0), p, W, H, VFOV); + if (!l.behind && !r.behind) { + const k = (r.y - l.y) / (r.x - l.x || 1); + const yAt = (x) => l.y + k * (x - l.x); + actx.beginPath(); + actx.moveTo(-10, yAt(-10)); + actx.lineTo(W + 10, yAt(W + 10)); + actx.lineTo(W + 10, H + 10); + actx.lineTo(-10, H + 10); + actx.closePath(); + const gg = actx.createLinearGradient(0, hy, 0, hy + H * 0.6); + gg.addColorStop(0, '#1a2633'); + gg.addColorStop(1, '#04060a'); + actx.fillStyle = gg; + actx.fill(); + } else if (p.el < 0) actx.fillRect(0, 0, W, H); + actx.restore(); + for (const s of stars) { + if (s.el < 2) continue; + const q = projectDir(dirFromAzEl(s.az, s.el), p, W, H, VFOV); + if (q.behind || q.x < 0 || q.y < 0 || q.x > W || q.y > H) continue; + actx.fillStyle = `rgba(255,255,255,${0.25 + s.m * 0.6})`; + actx.fillRect(q.x, q.y, s.m > 0.9 ? 1.8 : 1.1, s.m > 0.9 ? 1.8 : 1.1); + } + } + + function drawHorizon(p) { + actx.lineWidth = 1; + for (const el of [-20, -10, 0, 10, 20, 30, 45, 60]) { + const pts = []; + for (let d = -70; d <= 70; d += 5) { + const q = projectDir(dirFromAzEl(p.az + d, el), p, W, H, VFOV); + if (!q.behind) pts.push(q); + } + if (pts.length < 2) continue; + actx.strokeStyle = + el === 0 ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.12)'; + actx.setLineDash(el === 0 ? [] : [3, 6]); + actx.beginPath(); + pts.forEach((q, i) => + i ? actx.lineTo(q.x, q.y) : actx.moveTo(q.x, q.y), + ); + actx.stroke(); + actx.setLineDash([]); + if (el && pts.length) { + const mid = pts[Math.floor(pts.length / 2)]; + actx.fillStyle = 'rgba(255,255,255,0.35)'; + actx.font = '600 10px ui-rounded, system-ui'; + actx.fillText(`${el > 0 ? '+' : ''}${el}°`, mid.x + 6, mid.y - 3); + } + } + // Cardinal labels on the horizon. + const card = [ + ['N', 0], + ['E', 90], + ['S', 180], + ['W', 270], + ]; + actx.font = '700 13px ui-rounded, system-ui'; + actx.textAlign = 'center'; + for (const [n, az] of card) { + const q = projectDir(dirFromAzEl(az, 0), p, W, H, VFOV); + if (q.behind || q.x < -20 || q.x > W + 20) continue; + actx.fillStyle = n === 'N' ? '#ff6b61' : 'rgba(255,255,255,0.8)'; + actx.fillText(n, q.x, q.y + 18); + } + actx.textAlign = 'left'; + } + + function refreshContacts() { + const now = performance.now(); + if (now - contactsAt < 1000) return; + contactsAt = now; + const o = observer(); + contacts = globe.contactsNear(o.lon, o.lat, 150); + } + + function rrect(x, y, w, hh, r) { + actx.beginPath(); + actx.moveTo(x + r, y); + actx.arcTo(x + w, y, x + w, y + hh, r); + actx.arcTo(x + w, y + hh, x, y + hh, r); + actx.arcTo(x, y + hh, x, y, r); + actx.arcTo(x, y, x + w, y, r); + actx.closePath(); + } + + function drawLook(p) { + actx.setTransform(dpr, 0, 0, dpr, 0, 0); + actx.clearRect(0, 0, W, H); + if (!camOk) drawSky(p); + drawHorizon(p); + refreshContacts(); + const o = observer(); + const items = []; + const offscreen = []; + for (const c of contacts) { + const la = lookAngles(o, c); + if (la.el < -2) continue; + const q = projectDir(la.enu, p, W, H, VFOV); + const vis = !q.behind && q.x > 0 && q.x < W && q.y > 60 && q.y < H - 40; + (vis ? items : offscreen).push({ c, la, q }); + } + items.sort((a, b) => a.la.range - b.la.range); + const placed = []; + let shown = 0; + for (const it of items.slice(0, 24)) { + const { c, la, q } = it; + const col = KIND_COLOR[c.kind] || '#fff'; + const near = la.range < 40e3; + // Marker + actx.strokeStyle = col; + actx.lineWidth = 1.6; + actx.beginPath(); + if (c.kind === 'satellite' || c.kind === 'station') { + actx.moveTo(q.x, q.y - 7); + actx.lineTo(q.x + 7, q.y); + actx.lineTo(q.x, q.y + 7); + actx.lineTo(q.x - 7, q.y); + actx.closePath(); + } else if (c.kind === 'ship' || c.kind === 'vessel') { + actx.rect(q.x - 6, q.y - 6, 12, 12); + } else actx.arc(q.x, q.y, near ? 9 : 6.5, 0, Math.PI * 2); + actx.stroke(); + actx.fillStyle = col; + actx.beginPath(); + actx.arc(q.x, q.y, 1.8, 0, Math.PI * 2); + actx.fill(); + if (shown >= 12) continue; + // Callout with simple de-cluttering. + const title = c.title || c.id; + const sub = + c.kind === 'satellite' || c.kind === 'station' + ? `${Math.round(c.altM / 1000)} km · ${fmtDist(la.range)}` + : `${c.altM > 50 ? fmtAlt(c.altM) + ' · ' : ''}${fmtDist(la.range)}`; + actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace'; + const tw = Math.max( + actx.measureText(title).width, + ((actx.font = '500 10.5px ui-rounded, system-ui'), + actx.measureText(sub).width), + ); + const bw = tw + 18; + const bh = 36; + let bx = q.x + 16; + let by = q.y - bh - 12; + if (bx + bw > W - 8) bx = q.x - 16 - bw; + for ( + let k = 0; + k < 6 && + placed.some( + (r) => + !( + bx + bw < r.x || + bx > r.x + r.w || + by + bh < r.y || + by > r.y + r.h + ), + ); + k += 1 + ) + by -= bh + 4; + if (by < 64) continue; + placed.push({ x: bx, y: by, w: bw, h: bh }); + shown += 1; + actx.strokeStyle = 'rgba(255,255,255,0.45)'; + actx.lineWidth = 1; + actx.beginPath(); + actx.moveTo(q.x + (bx > q.x ? 6 : -6), q.y - 6); + actx.lineTo(bx > q.x ? bx : bx + bw, by + bh); + actx.stroke(); + rrect(bx, by, bw, bh, 10); + actx.fillStyle = 'rgba(8,11,17,0.72)'; + actx.fill(); + actx.strokeStyle = 'rgba(255,255,255,0.14)'; + actx.stroke(); + actx.fillStyle = col; + actx.fillRect(bx, by + 8, 2.5, bh - 16); + actx.fillStyle = '#fff'; + actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace'; + actx.fillText(title, bx + 10, by + 15); + actx.fillStyle = 'rgba(235,241,250,0.65)'; + actx.font = '500 10.5px ui-rounded, system-ui'; + actx.fillText(sub, bx + 10, by + 29); + } + // Edge arrows for the nearest contacts outside the view. + offscreen.sort((a, b) => a.la.range - b.la.range); + for (const it of offscreen.slice(0, 5)) { + const d = ((it.la.az - p.az + 540) % 360) - 180; + const x = d < 0 ? 18 : W - 18; + const y = clamp(H / 2 - (it.la.el - p.el) * (H / VFOV), 90, H - 200); + actx.fillStyle = KIND_COLOR[it.c.kind] || '#fff'; + actx.globalAlpha = 0.85; + actx.beginPath(); + actx.moveTo(x + (d < 0 ? -7 : 7), y); + actx.lineTo(x + (d < 0 ? 5 : -5), y - 7); + actx.lineTo(x + (d < 0 ? 5 : -5), y + 7); + actx.closePath(); + actx.fill(); + actx.globalAlpha = 1; + } + const counts = {}; + for (const c of contacts) counts[c.kind] = (counts[c.kind] || 0) + 1; + const legend = Object.entries(counts) + .map( + ([k, n]) => + `${n} ${(KIND_LABEL[k] || [k, `${k}s`])[n > 1 ? 1 : 0]}`, + ) + .join(''); + const lg = $('.cap-legend', view); + const key = `${legend}|${o.simulated}|${sensors.state.hasOrientation}`; + if (lg.dataset.key !== key) { + lg.dataset.key = key; + lg.innerHTML = `${legend}${o.simulated ? 'simulated position · ' : ''}${sensors.state.hasOrientation ? 'compass-aligned' : 'drag to look'}`; + } + } + + let hudT = 0; + function drawWitness(p) { + actx.setTransform(dpr, 0, 0, dpr, 0, 0); + actx.clearRect(0, 0, W, H); + if (!camOk) { + drawSky(p); + actx.fillStyle = 'rgba(255,255,255,0.5)'; + actx.font = '600 12px ui-rounded, system-ui'; + actx.textAlign = 'center'; + actx.fillText('NO CAMERA · TEST FRAME', W / 2, H / 2 + 60); + actx.textAlign = 'left'; + } + // Thirds grid + actx.strokeStyle = 'rgba(255,255,255,0.14)'; + actx.lineWidth = 0.75; + actx.beginPath(); + for (const k of [1 / 3, 2 / 3]) { + actx.moveTo(W * k, 0); + actx.lineTo(W * k, H); + actx.moveTo(0, H * k); + actx.lineTo(W, H * k); + } + actx.stroke(); + level.style.transform = `rotate(${(-p.roll).toFixed(1)}deg)`; + level.parentElement.classList.toggle('level', Math.abs(p.roll) < 1.5); + const now = performance.now(); + if (now - hudT > 100) { + hudT = now; + updateHud(p); + } + } + + function updateHud(p) { + const f = sensors.state.fix; + const set = (k, v) => { + const el = view.querySelector(`[data-h="${k}"]`); + if (el && el.textContent !== v) el.textContent = v; + }; + set('lat', f ? f.lat.toFixed(5) : '—'); + set('lon', f ? f.lon.toFixed(5) : '—'); + set( + 'acc', + f ? `${Math.round(f.acc)} m` : sensors.state.geoError ? 'denied' : '…', + ); + set('hdg', `${Math.round(p.az)}°`); + set('pitch', `${p.el >= 0 ? '+' : ''}${p.el.toFixed(0)}°`); + set('roll', `${p.roll >= 0 ? '+' : ''}${p.roll.toFixed(0)}°`); + const s = sensors.recording; + set( + 'session', + s + ? `Recording session · ${sensors.state.hasMotion ? 'IMU live' : 'no IMU'} · ${f ? 'GPS fix' : 'no GPS'}` + : 'Session idle', + ); + $('.hud-rec', view).classList.toggle('on', s); + // IMU trace (sized lazily: the HUD is hidden in Look). + const hr = traceC.getBoundingClientRect(); + if (Math.round(hr.width * dpr) !== traceC.width) { + traceC.width = Math.round(hr.width * dpr); + traceC.height = Math.round(hr.height * dpr); + } + const tw = traceC.width / dpr; + const th = traceC.height / dpr; + trctx.setTransform(dpr, 0, 0, dpr, 0, 0); + trctx.clearRect(0, 0, tw, th); + const tr = sensors.trace; + if (tr.length > 2) { + const vals = tr.map((x) => x.v); + const d = sparkPath(vals, tw, th, 2); + trctx.strokeStyle = '#FFD166'; + trctx.lineWidth = 1.4; + trctx.stroke(new Path2D(d)); + } else { + trctx.strokeStyle = 'rgba(255,255,255,0.25)'; + trctx.setLineDash([2, 3]); + trctx.beginPath(); + trctx.moveTo(0, th / 2); + trctx.lineTo(tw, th / 2); + trctx.stroke(); + trctx.setLineDash([]); + } + } + + function drawTape(p) { + const tr = tape.getBoundingClientRect(); + if ( + Math.round(tr.width * dpr) !== tape.width || + Math.round(tr.height * dpr) !== tape.height + ) { + tape.width = Math.round(tr.width * dpr); + tape.height = Math.round(tr.height * dpr); + } + const w = tape.width / dpr; + const hh = tape.height / dpr; + if (!w) return; + tctx.setTransform(dpr, 0, 0, dpr, 0, 0); + tctx.clearRect(0, 0, w, hh); + const pxPerDeg = w / 90; + tctx.textAlign = 'center'; + for (let d = Math.floor(p.az - 50); d <= p.az + 50; d += 1) { + const deg = ((d % 360) + 360) % 360; + if (deg % 5) continue; + const x = w / 2 + (d - p.az) * pxPerDeg; + const major = deg % 15 === 0; + tctx.strokeStyle = major + ? 'rgba(255,255,255,0.7)' + : 'rgba(255,255,255,0.28)'; + tctx.beginPath(); + tctx.moveTo(x, hh - 4); + tctx.lineTo(x, hh - (major ? 12 : 7)); + tctx.stroke(); + if (deg % 45 === 0) { + const n = { + 0: 'N', + 45: 'NE', + 90: 'E', + 135: 'SE', + 180: 'S', + 225: 'SW', + 270: 'W', + 315: 'NW', + }[deg]; + tctx.font = '700 11px ui-rounded, system-ui'; + tctx.fillStyle = deg === 0 ? '#ff6b61' : '#fff'; + tctx.fillText(n, x, hh - 16); + } else if (deg % 15 === 0) { + tctx.font = '500 9px ui-rounded, system-ui'; + tctx.fillStyle = 'rgba(255,255,255,0.5)'; + tctx.fillText(String(deg), x, hh - 16); + } + } + const fade = (x0, x1) => { + const g = tctx.createLinearGradient(x0, 0, x1, 0); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + tctx.globalCompositeOperation = 'destination-out'; + tctx.fillStyle = g; + tctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), hh); + tctx.globalCompositeOperation = 'source-over'; + }; + fade(0, 30); + fade(w, w - 30); + $('.cap-caret', view).dataset.deg = `${Math.round(p.az)}°`; + } + + /* ---------- shutter ---------- */ + let hold = null; + let recording = null; + shutter.addEventListener('pointerdown', (e) => { + if (mode === 'look') return; + e.preventDefault(); + hold = setTimeout(startVideo, 380); + }); + const release = async () => { + if (mode === 'look') return; + if (hold) { + clearTimeout(hold); + hold = null; + if (!recording) await takePhoto(); + } + if (recording) await stopVideo(); + }; + shutter.addEventListener('pointerup', release); + shutter.addEventListener('pointerleave', () => recording && release()); + shutter.addEventListener('click', (e) => { + if (mode === 'look') { + setMode('witness'); + return; + } + if (e.detail === 0) takePhoto(); + }); + + async function startVideo() { + hold = null; + if (!camOk || !camera.canRecord()) { + showStatus('Video needs a camera — sealing a still instead'); + return takePhoto(); + } + try { + camera.startVideo(); + } catch (err) { + showStatus(err.message); + return; + } + recording = { + t0: performance.now(), + timer: setTimeout(() => stopVideo(), 15000), + }; + root.classList.add('recording'); + haptic('medium'); + } + async function stopVideo() { + const r = recording; + recording = null; + clearTimeout(r?.timer); + root.classList.remove('recording'); + const blob = await camera.stopVideo(); + if (blob?.size) await sealMedia(blob, 'video'); + } + + async function takePhoto() { + flash(); + haptic('light'); + let blob; + let test = false; + try { + blob = await camera.photo(); + } catch { + test = true; + blob = await new Promise((resolve) => { + const c = document.createElement('canvas'); + c.width = Math.round(W * dpr); + c.height = Math.round(H * dpr); + const x = c.getContext('2d'); + x.drawImage(ar, 0, 0); + x.fillStyle = 'rgba(255,209,102,0.9)'; + x.font = `${Math.round(16 * dpr)}px ui-monospace, monospace`; + x.fillText( + `TEST FRAME ${new Date().toISOString()}`, + 16 * dpr, + c.height - 24 * dpr, + ); + c.toBlob(resolve, 'image/jpeg', 0.9); + }); + } + await sealMedia(blob, 'photo', test); + } + + function flash() { + const f = h('
'); + root.appendChild(f); + f.animate([{ opacity: 0.85 }, { opacity: 0 }], { + duration: reducedMotion() ? 80 : 320, + easing: 'ease-out', + }).finished.then(() => f.remove()); + } + + async function sealMedia(blob, kind, test = false) { + const session = sensors.endSession() || { + start: new Date().toISOString(), + end: new Date().toISOString(), + imu: [], + gps: [], + heading: null, + }; + if (!session.heading) { + const p = pose(); + session.heading = { + deg: Math.round(p.az * 10) / 10, + pitch: Math.round(p.el * 10) / 10, + roll: 0, + source: 'simulated', + }; + } + session.mode = kind; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const context = { + app: 'gods-eye-mobile-prototype/0.1', + device: + navigator.userAgentData?.platform || navigator.platform || 'unknown', + mobile: + navigator.userAgentData?.mobile ?? /Mobi/i.test(navigator.userAgent), + ua: navigator.userAgent, + screen: { + w: screen.width, + h: screen.height, + dpr: devicePixelRatio, + orientation: screen.orientation?.type || null, + }, + timezone: tz, + tzOffsetMin: new Date().getTimezoneOffset(), + networkTimeOffsetMs: netOffset, + camera: camera.info(), + sensors: { + motion: sensors.state.hasMotion, + orientation: sensors.state.hasOrientation, + absoluteHeading: + sensors.state.absolute || sensors.state.compass != null, + }, + testFrame: test || undefined, + }; + const url = URL.createObjectURL(blob); + showSealing(url, kind, test); + try { + const bundle = await seal({ media: blob, session, context }); + lastCapture = { bundle, url }; + setThumb(url, kind); + showSealed(bundle, url, kind, test); + onSealed?.(bundle); + } catch (err) { + console.error(err); + sealEl.hidden = true; + toast(`Could not seal: ${err.message}`); + } + if (active && mode === 'witness') sensors.beginSession(); + } + + function setThumb(url, kind) { + const t = $('.cap-thumb', view); + t.innerHTML = + kind === 'video' + ? `` + : `Last sealed capture`; + t.classList.add('has'); + } + + /* ---------- seal ceremony ---------- */ + const SEAL_D = (() => { + let d = ''; + for (let i = 0; i <= 96; i += 1) { + const a = (i / 96) * Math.PI * 2; + const r = 40 + 4.2 * Math.cos(a * 12); + d += `${i ? 'L' : 'M'}${(50 + r * Math.cos(a)).toFixed(2)} ${(50 + r * Math.sin(a)).toFixed(2)}`; + } + return `${d}Z`; + })(); + + function showSealing(url, kind, test) { + sealEl.hidden = false; + sealEl.innerHTML = `
+
${kind === 'video' ? `` : `Captured frame`}${test ? 'Test frame' : ''}
+
+
Sealing…
+
+
`; + scramble($('.seal-hash', sealEl), null); + } + + function scramble(el, finalText) { + const hex = '0123456789abcdef'; + const len = 64; + let n = 0; + clearInterval(scramble.t); + scramble.t = setInterval( + () => { + n += 1; + let s = ''; + for (let i = 0; i < len; i += 1) { + const settled = finalText && i < (n - 6) * 4; + s += settled ? finalText[i] : hex[(Math.random() * 16) | 0]; + } + el.innerHTML = s + .match(/.{16}/g) + .map((g) => `${g}`) + .join(''); + if (finalText && (n - 6) * 4 >= len) clearInterval(scramble.t); + }, + reducedMotion() ? 1 : 28, + ); + } + + function showSealed(bundle, url, kind, test) { + const card = $('.seal-card', sealEl); + card.classList.add('sealed'); + haptic('seal'); + $('.seal-state', sealEl).innerHTML = + `Sealed · capture #${bundle.seq}`; + scramble($('.seal-hash', sealEl), bundle.hash); + const imu = bundle.session.imu; + const mags = imu.map((s) => Math.hypot(s.ax ?? 0, s.ay ?? 0, s.az ?? 0)); + const dur = imu.length > 1 ? (imu[imu.length - 1].t - imu[0].t) / 1000 : 0; + const gps = bundle.session.gps; + const f = gps[gps.length - 1]; + card.insertAdjacentHTML( + 'beforeend', + `
${mags.length > 2 ? `` : '
No motion sensors on this device
'}IMU ${imu.length} samples${dur ? ` · ${dur.toFixed(1)} s` : ''}
+
+
Signed
ECDSA P-256 · key ${esc(bundle.keyId)}
+
Chain
#${bundle.seq} ← ${bundle.prev ? `${esc(bundle.prev.slice(0, 12))}…` : 'genesis'}
+
Where
${f ? `${f.lat.toFixed(5)}, ${f.lon.toFixed(5)} ±${Math.round(f.acc)} m` : 'no GPS fix'}
+
Heading
${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}
+
Media
${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB
+
+
`, + ); + } + + sealEl.addEventListener('click', (e) => { + const a = e.target.closest('[data-seal]')?.dataset.seal; + if (a === 'done') sealEl.hidden = true; + if (a === 'verify' && lastCapture) onVerify?.(lastCapture.bundle); + }); + + $('.cap-thumb', view).addEventListener('click', () => { + if (lastCapture) onVerify?.(lastCapture.bundle); + else toast('No captures yet — switch to Witness and tap the shutter'); + }); + view.querySelector('[data-act="recenter"]').addEventListener('click', () => { + const s = sensors.state; + showStatus( + `${camOk ? 'Camera on' : 'No camera'} · ${s.hasOrientation ? (s.compass != null ? 'compass' : s.absolute ? 'absolute orientation' : 'relative orientation') : 'no motion sensors'} · ${s.fix ? `GPS ±${Math.round(s.fix.acc)} m` : s.geoError ? 'location denied' : 'locating…'}`, + 3500, + ); + }); + + return { + enter, + leave, + setMode, + get mode() { + return mode; + }, + sensors, + }; +} diff --git a/mobile/ui/dom.js b/mobile/ui/dom.js new file mode 100644 index 000000000..3bc6d2ff7 --- /dev/null +++ b/mobile/ui/dom.js @@ -0,0 +1,308 @@ +/** Small DOM, formatting, motion and haptic helpers shared by the shell. */ + +export const $ = (sel, root = document) => root.querySelector(sel); +export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; + +const ESC = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; +/** Escape untrusted text for HTML interpolation. */ +export const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]); + +/** Build one element from an HTML string. */ +export function h(html) { + const t = document.createElement('template'); + t.innerHTML = html.trim(); + return t.content.firstElementChild; +} + +export const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); +export const lerp = (a, b, t) => a + (b - a) * t; + +/** "2 min ago", "just now", "3 h ago". */ +export function ago(ts, now = Date.now()) { + if (!ts) return ''; + const t = typeof ts === 'number' ? ts : new Date(ts).getTime(); + const s = Math.max(0, Math.round((now - t) / 1000)); + if (s < 45) return 'just now'; + const m = Math.round(s / 60); + if (m < 60) return `${m} min ago`; + const hrs = Math.round(m / 60); + if (hrs < 48) return `${hrs} h ago`; + return `${Math.round(hrs / 24)} d ago`; +} + +export function fmtNum(v, digits = 0) { + if (v == null || v === '') return '—'; + if (typeof v !== 'number') return String(v); + if (Math.abs(v) >= 10000) + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(v); + return v.toLocaleString(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }); +} + +export function fmtCoord(lat, lon, d = 4) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return '—'; + const ns = lat >= 0 ? 'N' : 'S'; + const ew = lon >= 0 ? 'E' : 'W'; + return `${Math.abs(lat).toFixed(d)}° ${ns} ${Math.abs(lon).toFixed(d)}° ${ew}`; +} + +export function fmtDist(m) { + if (!Number.isFinite(m)) return '—'; + if (m < 1000) return `${Math.round(m)} m`; + if (m < 100000) return `${(m / 1000).toFixed(1)} km`; + return `${Math.round(m / 1000).toLocaleString()} km`; +} + +export function fmtAlt(m) { + if (!Number.isFinite(m)) return '—'; + if (m > 100000) return `${Math.round(m / 1000).toLocaleString()} km`; + const ft = m * 3.28084; + return `${Math.round(ft / 100) * 100 >= 1000 ? (Math.round(ft / 100) * 100).toLocaleString() : Math.round(ft)} ft`; +} + +/* ---------- Settings (per-viewer conveniences only) ---------- */ + +const SETTINGS_KEY = 'gev.mobile.settings.v1'; +const defaults = { + quality: 'auto', + reduceMotion: 'system', + alertRadiusKm: 250, + haptics: true, + followed: [], + lens: 'cinematic', +}; + +let settings = { ...defaults }; +try { + const raw = localStorage.getItem(SETTINGS_KEY); + if (raw) settings = { ...defaults, ...JSON.parse(raw) }; +} catch { + /* storage unavailable: defaults */ +} + +const subs = new Set(); +export const store = { + get: (k) => settings[k], + set(k, v) { + settings[k] = v; + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch { + /* ignore */ + } + subs.forEach((fn) => fn(k, v)); + }, + on(fn) { + subs.add(fn); + return () => subs.delete(fn); + }, + isFollowed: (id) => settings.followed.includes(id), + toggleFollow(id) { + const list = settings.followed.includes(id) + ? settings.followed.filter((x) => x !== id) + : [id, ...settings.followed]; + store.set('followed', list); + return list.includes(id); + }, +}; + +/* ---------- Motion ---------- */ + +const rmQuery = matchMedia('(prefers-reduced-motion: reduce)'); +export function reducedMotion() { + const pref = settings.reduceMotion; + if (pref === 'on') return true; + if (pref === 'off') return false; + return rmQuery.matches; +} + +export function applyMotionPref() { + const pref = settings.reduceMotion; + if (pref === 'system') delete document.documentElement.dataset.reduceMotion; + else document.documentElement.dataset.reduceMotion = pref; +} + +/** Spring easing string for WAAPI (falls back to cubic-bezier). */ +export const SPRING = (() => { + try { + if (CSS.supports('transition-timing-function', 'linear(0, 1)')) + return 'linear(0, 0.009, 0.035 2.1%, 0.141 4.4%, 0.723 12.9%, 0.938 16.7%, 1.017 19.8%, 1.067, 1.089 25.9%, 1.085 29.1%, 1.05 35.4%, 1.016 42.4%, 0.999 51.8%, 0.996 58.3%, 1)'; + } catch { + /* ignore */ + } + return 'cubic-bezier(0.32, 1.25, 0.4, 1)'; +})(); + +export const SPRING_SOFT = (() => { + try { + if (CSS.supports('transition-timing-function', 'linear(0, 1)')) + return 'linear(0, 0.06 3%, 0.25 7.5%, 0.6 15%, 0.84 22%, 0.96 30%, 1.012 38%, 1.02 46%, 1.006 60%, 1)'; + } catch { + /* ignore */ + } + return 'cubic-bezier(0.25, 1.1, 0.4, 1)'; +})(); + +export const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +export const nextFrame = () => + new Promise((r) => requestAnimationFrame(() => r())); + +/* ---------- Haptics ---------- */ + +const PATTERNS = { + tick: 5, + light: 8, + medium: 14, + success: [10, 40, 18], + seal: [12, 60, 12, 60, 28], + warn: [20, 50, 20], +}; +export function haptic(kind = 'tick') { + if (!settings.haptics) return; + try { + if (navigator.vibrate) navigator.vibrate(PATTERNS[kind] ?? 5); + } catch { + /* ignore */ + } +} + +/* ---------- Toast ---------- */ + +let toastTimer = 0; +export function toast(text, { icon: iconHtml = '' } = {}) { + let el = document.getElementById('toast'); + if (!el) { + el = h( + '
', + ); + document.body.appendChild(el); + } + el.innerHTML = `${iconHtml}${esc(text)}`; + el.classList.remove('show'); + void el.offsetWidth; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), 2200); +} + +/** Inline SVG sparkline path for a numeric series. */ +export function sparkPath(values, w, h, pad = 1.5) { + const v = values.filter(Number.isFinite); + if (v.length < 2) return ''; + let lo = Math.min(...v); + let hi = Math.max(...v); + if (hi - lo < 1e-9) { + hi += 1; + lo -= 1; + } + return v + .map((y, i) => { + const px = pad + (i / (v.length - 1)) * (w - pad * 2); + const py = pad + (1 - (y - lo) / (hi - lo)) * (h - pad * 2); + return `${i ? 'L' : 'M'}${px.toFixed(1)} ${py.toFixed(1)}`; + }) + .join(''); +} + +/** Long press helper; returns a disposer. */ +export function onLongPress(el, fn, ms = 480) { + let timer = 0; + let sx = 0; + let sy = 0; + let fired = false; + const down = (e) => { + fired = false; + sx = e.clientX; + sy = e.clientY; + timer = setTimeout(() => { + fired = true; + fn(e); + }, ms); + }; + const move = (e) => { + if (Math.hypot(e.clientX - sx, e.clientY - sy) > 8) clearTimeout(timer); + }; + const up = () => clearTimeout(timer); + el.addEventListener('pointerdown', down, { passive: true }); + el.addEventListener('pointermove', move, { passive: true }); + el.addEventListener('pointerup', up, { passive: true }); + el.addEventListener('pointercancel', up, { passive: true }); + el.addEventListener( + 'click', + (e) => { + if (fired) { + e.stopImmediatePropagation(); + e.preventDefault(); + fired = false; + } + }, + true, + ); + el.addEventListener('contextmenu', (e) => e.preventDefault()); +} + +/** A tiny event emitter. */ +export function emitter() { + const m = new Map(); + return { + on(ev, fn) { + if (!m.has(ev)) m.set(ev, new Set()); + m.get(ev).add(fn); + return () => m.get(ev)?.delete(fn); + }, + off(ev, fn) { + m.get(ev)?.delete(fn); + }, + emit(ev, data) { + m.get(ev)?.forEach((fn) => { + try { + fn(data); + } catch (err) { + console.error(err); + } + }); + }, + }; +} + +/** Esri World Imagery export URL for a lon/lat box around a point. */ +export function esriThumb(lon, lat, spanDeg = 1.2, w = 480, hgt = 600) { + const aspect = hgt / w; + const dLon = spanDeg / Math.max(0.2, Math.cos((lat * Math.PI) / 180)); + const dLat = spanDeg * aspect; + const west = lon - dLon / 2; + const east = lon + dLon / 2; + const south = Math.max(-85, lat - dLat / 2); + const north = Math.min(85, lat + dLat / 2); + return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export?bbox=${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}&bboxSR=4326&imageSR=3857&size=${w},${hgt}&format=jpg&f=image`; +} + +/** Safe-area insets in px (env() read through a probe element). */ +let insetProbe = null; +export function safeInsets() { + if (!insetProbe) { + insetProbe = document.createElement('div'); + insetProbe.style.cssText = + 'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)'; + document.body.appendChild(insetProbe); + } + const cs = getComputedStyle(insetProbe); + return { + top: parseFloat(cs.paddingTop) || 0, + right: parseFloat(cs.paddingRight) || 0, + bottom: parseFloat(cs.paddingBottom) || 0, + left: parseFloat(cs.paddingLeft) || 0, + }; +} diff --git a/mobile/ui/feed.js b/mobile/ui/feed.js new file mode 100644 index 000000000..7505da684 --- /dev/null +++ b/mobile/ui/feed.js @@ -0,0 +1,350 @@ +/** + * Now: a two-column, RedNote-style masonry of live situations with a large + * collapsing title, section tabs, pull-to-refresh and skeleton loading. + */ + +import { icon, FAMILY_ICON, FAMILIES } from './icons.js'; +import { $, $$, esc, ago, fmtNum, clamp, haptic } from './dom.js'; + +const SECTIONS = [ + { id: 'now', label: 'Happening now' }, + { id: 'near', label: 'Near you' }, + { id: 'recon', label: 'Reconstructions' }, + { id: 'always', label: 'Always on' }, +]; + +const ASPECTS = [1.25, 1, 1.33, 0.82, 1.18, 0.95]; +const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name])); + +function hashStr(s) { + let x = 2166136261; + for (let i = 0; i < s.length; i += 1) + x = Math.imul(x ^ s.charCodeAt(i), 16777619); + return x >>> 0; +} + +/** Height/width ratio for a card: from the thumbnail URL when known, else stable per id. */ +function aspectFor(s) { + const m = /size=(\d+),(\d+)/.exec(s.thumbnail || ''); + if (m) return clamp(Number(m[2]) / Number(m[1]), 0.72, 1.4); + if (s.kind === 'reconstruction') return 1.3; + return ASPECTS[hashStr(String(s.id)) % ASPECTS.length]; +} + +export function isReconstruction(s) { + return ( + s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread + ); +} +export function isAlwaysOn(s) { + return s.section === 'always' || !!s.alwaysOn; +} + +function distKm(a, b) { + const R = 6371; + 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; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(x))); +} + +export function createFeed( + view, + { getSituations, onOpen, onLocate, bindScroll }, +) { + view.innerHTML = ` + + +
+

Now

+
Loading the planet…
+
+ ${SECTIONS.map((s, i) => ``).join('')} + +
+ +
+ +
`; + + const inner = $('.feed-inner', view); + const cols = $$('.mcol', view); + const masonry = $('.masonry', view); + const tabs = $('.feed-tabs', view); + const ink = $('.feed-tabs-ink', view); + const count = $('.feed-count', view); + const note = $('.feed-note', view); + const ptr = $('.ptr', view); + let section = 'now'; + let all = []; + let userLoc = null; + const cardEls = new Map(); + let selectedId = null; + + function skeleton() { + cols.forEach((c, ci) => { + c.innerHTML = [0, 1, 2] + .map( + (i) => + `
`, + ) + .join(''); + }); + } + + function cardHtml(s) { + const m = s.metrics?.[0]; + const ar = aspectFor(s); + const fam = FAM_NAME[s.family] || s.family; + return `
+ +
+ +
+ ${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)} + ${s.live ? 'LIVE' : isReconstruction(s) ? `${icon('threads')}4D` : ''} +
+ ${m ? `
${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}${m.unit ? `${esc(m.unit)}` : ''}${esc(m.label)}
` : ''} +
+
+

${esc(s.title)}

+
${icon('pin')}${esc(s.place || '')}
+
${esc(shortSource(s.source))}${esc(ago(s.updatedAt))}
+
+
`; + } + + function shortSource(src = '') { + return String(src).split(/[·(]/)[0].trim() || 'Live feed'; + } + + function filtered() { + if (section === 'recon') return all.filter(isReconstruction); + if (section === 'always') return all.filter(isAlwaysOn); + if (section === 'near') { + const at = userLoc || { lon: -122.42, lat: 37.77 }; + return [...all] + .filter((s) => Number.isFinite(s.lon)) + .sort((a, b) => distKm(at, a) - distKm(at, b)) + .slice(0, 8); + } + return all + .filter((s) => !isReconstruction(s) && !isAlwaysOn(s)) + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + } + + function render() { + const list = filtered(); + cardEls.clear(); + const heights = [0, 0]; + const parts = [[], []]; + for (const s of list) { + const col = heights[0] <= heights[1] ? 0 : 1; + parts[col].push(cardHtml(s)); + heights[col] += aspectFor(s) + 0.42; + } + cols.forEach((c, i) => (c.innerHTML = parts[i].join(''))); + $$('.card[data-id]', masonry).forEach((el) => { + cardEls.set(el.dataset.id, el); + el.classList.toggle('is-selected', el.dataset.id === selectedId); + }); + masonry.setAttribute('aria-busy', 'false'); + $('.feed-end', view).hidden = !list.length; + const live = all.filter((s) => s.live).length; + count.textContent = `${live} live · ${all.length} situations · ${new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}`; + if (section === 'near' && !userLoc) { + note.hidden = false; + note.innerHTML = `
${icon('location')}
See what’s around youSorted from San Francisco until you share your location. It stays on this device.
`; + } else note.hidden = true; + // Images fade in when decoded. + $$('img', masonry).forEach((img) => { + if (img.complete) img.classList.add('loaded'); + else + img.addEventListener('load', () => img.classList.add('loaded'), { + once: true, + }); + img.addEventListener('error', () => img.classList.add('failed'), { + once: true, + }); + }); + } + + function placeInk() { + const btn = $(`[data-sec="${section}"]`, tabs); + if (!btn) return; + ink.style.width = `${btn.offsetWidth - 20}px`; + ink.style.transform = `translateX(${btn.offsetLeft + 10}px)`; + } + + async function load({ quiet = false } = {}) { + if (!quiet) skeleton(); + try { + const prev = new Map(all.map((s) => [s.id, JSON.stringify(s.metrics)])); + all = await getSituations(); + render(); + if (quiet) + for (const s of all) + if (prev.has(s.id) && prev.get(s.id) !== JSON.stringify(s.metrics)) + cardEls.get(s.id)?.classList.add('pulse'); + } catch (err) { + console.error(err); + count.textContent = 'Could not reach the live feeds'; + } + } + + tabs.addEventListener('click', (e) => { + const b = e.target.closest('[data-sec]'); + if (!b || b.dataset.sec === section) return; + section = b.dataset.sec; + $$('[data-sec]', tabs).forEach((x) => + x.setAttribute('aria-selected', String(x === b)), + ); + haptic('tick'); + placeInk(); + b.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + behavior: 'smooth', + }); + masonry.classList.remove('swap'); + void masonry.offsetWidth; + masonry.classList.add('swap'); + render(); + }); + + view.addEventListener('click', async (e) => { + const hit = e.target.closest('.card-hit'); + if (hit) { + const card = hit.closest('.card'); + const s = all.find((x) => String(x.id) === card.dataset.id); + if (s) onOpen(s, card); + return; + } + const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'refresh') refresh(); + if (act === 'locate') { + userLoc = await onLocate(); + render(); + } + }); + + /* ---------- large title + nav bar ---------- */ + const title = $('.large-title', view); + view.addEventListener( + 'scroll', + () => { + const y = view.scrollTop; + view.classList.toggle('is-scrolled', y > 44); + if (y < 0) + title.style.transform = `scale(${1 + Math.min(0.08, -y / 900)})`; + else if (title.style.transform) title.style.transform = ''; + }, + { passive: true }, + ); + bindScroll(view); + + /* ---------- pull to refresh ---------- */ + let pull = null; + let refreshing = false; + const THRESH = 78; + view.addEventListener( + 'touchstart', + (e) => { + if (view.scrollTop > 0 || refreshing || e.touches.length !== 1) return; + pull = { y0: e.touches[0].clientY, d: 0, armed: false }; + }, + { passive: true }, + ); + view.addEventListener( + 'touchmove', + (e) => { + if (!pull) return; + const dy = e.touches[0].clientY - pull.y0; + if (dy <= 0) { + pull.d = 0; + setPull(0); + return; + } + if (view.scrollTop > 0) return; + e.preventDefault(); + pull.d = dy; + const d = (1 - 1 / ((dy * 0.55) / 320 + 1)) * 320; + setPull(d); + if (!pull.armed && d > THRESH) { + pull.armed = true; + haptic('light'); + } else if (pull.armed && d < THRESH) pull.armed = false; + }, + { passive: false }, + ); + view.addEventListener( + 'touchend', + () => { + if (!pull) return; + const armed = pull.armed; + pull = null; + if (armed) refresh(); + else setPull(0, true); + }, + { passive: true }, + ); + + function setPull(d, animate = false) { + const t = animate ? 'transform 420ms var(--spring)' : 'none'; + inner.style.transition = t; + inner.style.transform = d ? `translate3d(0, ${d}px, 0)` : ''; + ptr.style.transition = animate ? 'opacity 200ms linear' : 'none'; + ptr.style.opacity = String(clamp(d / THRESH, 0, 1)); + ptr.style.setProperty('--p', clamp(d / THRESH, 0, 1).toFixed(3)); + } + + async function refresh() { + if (refreshing) return; + refreshing = true; + ptr.classList.add('spinning'); + setPull(64, true); + const t = performance.now(); + await load({ quiet: true }); + const wait = Math.max(0, 700 - (performance.now() - t)); + setTimeout(() => { + ptr.classList.remove('spinning'); + setPull(0, true); + refreshing = false; + haptic('success'); + }, wait); + } + + // Keep "2 min ago" honest. + setInterval(() => { + $$('.ago[data-ts]', view).forEach((el) => { + if (el.dataset.ts) + el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts); + }); + }, 30000); + + new ResizeObserver(placeInk).observe(tabs); + requestAnimationFrame(placeInk); + + return { + load, + refresh, + cardFor: (id) => cardEls.get(String(id)) || null, + situations: () => all, + select(id) { + selectedId = id == null ? null : String(id); + cardEls.forEach((el, k) => + el.classList.toggle('is-selected', k === selectedId), + ); + }, + setLocation(loc) { + userLoc = loc; + if (section === 'near') render(); + }, + }; +} diff --git a/mobile/ui/globeChrome.js b/mobile/ui/globeChrome.js new file mode 100644 index 000000000..0536560aa --- /dev/null +++ b/mobile/ui/globeChrome.js @@ -0,0 +1,549 @@ +/** + * Globe chrome: camera readout, lens switcher, compass/home, attribution, + * the lens dock of eight families, Control-Center-style layer grids, + * the time ribbon and the pick card. + */ + +import { icon, FAMILIES, FAMILY_ICON } from './icons.js'; +import { + $, + $$, + h, + esc, + fmtNum, + haptic, + toast, + store, + onLongPress, +} from './dom.js'; +import { createSheet } from './sheet.js'; +import { createTimeRibbon } from './timeRibbon.js'; + +const LENSES = [ + { + id: 'cinematic', + name: 'Cinematic', + sw: 'linear-gradient(135deg,#0b2a4a,#3a77b8 55%,#f3b46b)', + }, + { + id: 'clear', + name: 'Clear', + sw: 'linear-gradient(135deg,#2a6fb0,#7fb7e6 60%,#e8f1f8)', + }, + { + id: 'night', + name: 'Night', + sw: 'radial-gradient(circle at 40% 60%,#ffcf6b 0,#c27a2a 8%,#0b1020 26%,#03050a)', + }, + { + id: 'thermal', + name: 'Thermal', + sw: 'linear-gradient(135deg,#120428,#7a1fa2 35%,#ff5a1f 70%,#ffe26b)', + }, + { + id: 'nvg', + name: 'NVG', + sw: 'radial-gradient(circle,#7dff9c 0,#1fa64a 45%,#04140a)', + }, +]; + +const STATUS_LABEL = { + live: 'Live', + stale: 'Stale', + off: 'Off', + error: 'Error', +}; + +export function createGlobeChrome({ + chrome, + stage, + leadView, + globe, + onOpenSituation, + onThread, + getSituations, + isTwoPane, +}) { + /* ---------- top chrome ---------- */ + const top = h(`
+
——
+
+ + + +
+
`); + const attribution = h( + '', + ); + const lensPop = + h(``); + const dock = + h(``); + const pick = h( + ``, + ); + const bottom = h('
'); + chrome.append(top, attribution, lensPop, pick, bottom); + bottom.append(dock); + + const ribbon = createTimeRibbon(bottom, { + getWindow: () => globe.timeWindow(), + onTime: (d) => globe.setTime(d), + }); + + /* ---------- camera readout ---------- */ + globe.on('camera', (c) => { + if (!c) return; + const alt = + c.height > 10000 + ? `${fmtNum(Math.round(c.height / 1000))} km` + : `${fmtNum(Math.round(c.height))} m`; + $('.gc-alt', top).textContent = alt; + $('.gc-ll', top).textContent = + `${Math.abs(c.lat).toFixed(2)}°${c.lat >= 0 ? 'N' : 'S'} ${Math.abs(c.lon).toFixed(2)}°${c.lon >= 0 ? 'E' : 'W'}`; + if (Number.isFinite(c.heading)) + $('.compass-n', top).style.transform = `rotate(${-c.heading}deg)`; + }); + + /* ---------- lens switcher ---------- */ + function setLens(id) { + globe.setLens(id); + store.set('lens', id); + $$('[data-lens]', lensPop).forEach((b) => + b.setAttribute('aria-checked', String(b.dataset.lens === id)), + ); + chrome.dataset.lens = id; + } + setLens(store.get('lens') || 'cinematic'); + lensPop.addEventListener('click', (e) => { + const b = e.target.closest('[data-lens]'); + if (!b) return; + haptic('tick'); + setLens(b.dataset.lens); + setTimeout(() => (lensPop.hidden = true), 160); + }); + document.addEventListener('pointerdown', (e) => { + if (!lensPop.hidden && !e.target.closest('.lens-pop, [data-act="lens"]')) + lensPop.hidden = true; + }); + + /* ---------- family counts + dock ---------- */ + function familyState() { + const layers = globe.layers(); + const st = {}; + for (const f of FAMILIES) + st[f.id] = { on: 0, total: 0, count: 0, layers: [] }; + for (const l of layers) { + const s = st[l.family]; + if (!s) continue; + s.total += 1; + s.layers.push(l); + if (l.on) { + s.on += 1; + s.count += Number(l.count) || 0; + } + } + return st; + } + function refreshDock() { + const st = familyState(); + $$('.fam-chip', dock).forEach((b) => { + const s = st[b.dataset.family]; + b.classList.toggle('on', s.on > 0); + b.querySelector('.fam-count').textContent = s.on ? fmtNum(s.count) : ''; + }); + if (gridFamily) renderGrid(gridFamily); + if (isTwoPane()) renderLeadView(); + } + globe.on('layers', () => refreshDock()); + + let solo = null; + dock.addEventListener('click', (e) => { + const b = e.target.closest('.fam-chip'); + if (b) openGrid(b.dataset.family); + }); + $$('.fam-chip', dock).forEach((b) => + onLongPress(b, () => { + haptic('medium'); + const fam = b.dataset.family; + if (solo?.family === fam) { + for (const l of globe.layers()) + globe.setLayer(l.id, solo.prev.has(l.id)); + solo = null; + toast('All lenses restored', { icon: icon('layers') }); + } else { + const prev = + solo?.prev || + new Set( + globe + .layers() + .filter((l) => l.on) + .map((l) => l.id), + ); + for (const f of FAMILIES) + if (f.id !== fam) globe.setFamily(f.id, false); + globe.setFamily(fam, true); + solo = { family: fam, prev }; + toast( + `Solo · ${FAMILIES.find((f) => f.id === fam).name}. Long-press again to restore`, + { icon: icon(FAMILY_ICON[fam]) }, + ); + } + dock.dataset.solo = solo?.family || ''; + refreshDock(); + }), + ); + + /* ---------- layer grid (Control Center) ---------- */ + const grid = createSheet({ + label: 'Layers', + detents: ['medium', 'large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'grid-sheet', + heights: { + medium: (H) => Math.min(Math.round(H * 0.62), 560), + large: (H) => Math.round(H * 0.92), + }, + }); + let gridFamily = null; + grid.on('close', () => (gridFamily = null)); + + function tileHtml(l) { + return ``; + } + + function renderGrid(fam) { + const st = familyState()[fam]; + const f = FAMILIES.find((x) => x.id === fam); + grid.sheet.dataset.family = fam; + grid.head.innerHTML = `
${icon(FAMILY_ICON[fam])} +

${f.name}

${st.on} of ${st.total} on · ${fmtNum(st.count)} contacts
+
`; + grid.body.innerHTML = `
${st.layers.map(tileHtml).join('')}
+

Long-press ${f.name} in the dock to solo it. Counts are live from each feed; dotted means stale.

`; + } + + function openGrid(fam) { + gridFamily = fam; + renderGrid(fam); + grid.open('medium'); + } + + grid.root.addEventListener('click', (e) => { + const t = e.target.closest('[data-layer]'); + if (t) { + const on = t.getAttribute('aria-checked') !== 'true'; + haptic('tick'); + t.classList.toggle('on', on); + t.setAttribute('aria-checked', String(on)); + globe.setLayer(t.dataset.layer, on); + setTimeout(refreshDock, 30); + return; + } + if (e.target.closest('[data-fam-toggle]')) { + const sw = e.target.closest('[data-fam-toggle]'); + const on = sw.getAttribute('aria-checked') !== 'true'; + haptic('light'); + globe.setFamily(gridFamily, on); + setTimeout(refreshDock, 30); + } + }); + + /* ---------- credits ---------- */ + const credits = createSheet({ + label: 'Attribution', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + heights: { large: (H) => Math.round(H * 0.7) }, + }); + function openCredits() { + const list = globe.credits() || []; + const fixed = [ + { + name: 'Bhote Koshi event pack', + text: 'GeoPera reconstruction centerline; Vantor WorldView-2/3 Open Data; GeoGeorgeShadrach public geolocation map. CC BY-NC 4.0 — not covered by the app’s MIT license.', + url: 'https://creativecommons.org/licenses/by-nc/4.0/', + }, + { + name: 'Esri World Imagery (feed thumbnails)', + text: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community', + url: 'https://www.esri.com', + }, + { + name: 'Place search', + text: '© OpenStreetMap contributors, via Nominatim', + url: 'https://www.openstreetmap.org/copyright', + }, + ]; + const all = [ + ...list, + ...fixed.filter((f) => !list.some((l) => l.name === f.name)), + ]; + credits.head.innerHTML = `

Data & attribution

Imagery, feeds and the people who made them
`; + credits.body.innerHTML = `
${all + .map((c) => { + const text = + c.text || + c.attribution || + String(c.html || '').replace(/<[^>]+>/g, ''); + const [head, ...rest] = + c.name || c.title + ? [c.name || c.title, text] + : text.split(/:\s(.+)/s); + return `
${esc(head)}${rest.filter(Boolean).length ? `${esc(rest.filter(Boolean).join(' '))}` : ''}${c.url ? `${esc(new URL(c.url, location.href).hostname)}` : ''}
`; + }) + .join('')}
`; + credits.open('large'); + } + + top.addEventListener('click', (e) => { + const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'lens') { + lensPop.hidden = !lensPop.hidden; + haptic('tick'); + } else if (act === 'home') { + haptic('tick'); + hidePick(); + globe.home(); + } else if (act === 'credits') openCredits(); + }); + attribution.addEventListener('click', openCredits); + + /* ---------- pick card ---------- */ + const KIND_ICON = { + aircraft: 'sky', + helicopter: 'sky', + military: 'sky', + ship: 'sea', + vessel: 'sea', + satellite: 'orbit', + station: 'orbit', + launch: 'orbit', + quake: 'hazards', + earthquake: 'hazards', + fire: 'flame', + cyclone: 'weather', + camera: 'eyes', + cctv: 'eyes', + }; + const KIND_FAM = { + aircraft: 'sky', + helicopter: 'sky', + military: 'sky', + ship: 'sea', + vessel: 'sea', + satellite: 'orbit', + station: 'orbit', + launch: 'orbit', + quake: 'hazards', + earthquake: 'hazards', + fire: 'hazards', + cyclone: 'hazards', + flood: 'hazards', + camera: 'eyes', + cctv: 'eyes', + }; + let picked = null; + /** Human rows from a pick's meta: known units formatted, internals dropped. */ + function metaRows(m) { + if (!m || typeof m !== 'object') return []; + const rows = []; + const num = (v) => typeof v === 'number' && Number.isFinite(v); + if (num(m.altitudeM) && m.altitudeM > 0) + rows.push([ + 'altitude', + m.altitudeM > 100000 + ? `${fmtNum(Math.round(m.altitudeM / 1000))} km` + : `${fmtNum(Math.round((m.altitudeM * 3.28084) / 100) * 100)} ft`, + ]); + if (num(m.speedMps)) + rows.push(['speed', `${Math.round(m.speedMps * 1.944)} kt`]); + if (num(m.headingDeg)) + rows.push(['heading', `${Math.round(m.headingDeg)}°`]); + if (num(m.verticalRateMps) && Math.abs(m.verticalRateMps) > 0.5) + rows.push(['climb', `${Math.round(m.verticalRateMps * 196.85)} fpm`]); + const skip = new Set([ + 'layer', + 'source', + 'altitudeM', + 'speedMps', + 'headingDeg', + 'verticalRateMps', + 'onGround', + 'class', + 'situation', + ]); + for (const [k, v] of Object.entries(m)) { + if ( + skip.has(k) || + v == null || + v === '' || + typeof v === 'object' || + typeof v === 'boolean' + ) + continue; + rows.push([ + k.replace(/([A-Z])/g, ' $1').toLowerCase(), + num(v) ? fmtNum(v, v % 1 ? 1 : 0) : String(v), + ]); + } + return rows; + } + function hidePick() { + pick.hidden = true; + picked = null; + chrome.classList.remove('has-pick'); + } + async function showPick(p) { + if (!p) return hidePick(); + if (p.kind === 'situation') { + const s = (await getSituations()).find( + (x) => String(x.id) === String(p.id), + ); + if (s) onOpenSituation(s); + return; + } + picked = p; + const fam = KIND_FAM[p.kind] || 'infra'; + const meta = metaRows(p.meta); + const followed = store.isFollowed(`contact:${p.id}`); + pick.dataset.family = fam; + pick.innerHTML = `
${icon(KIND_ICON[p.kind] || 'scope')} +
${esc(p.title || p.id)}
${esc(p.subtitle || p.kind)}
+
+ ${ + meta.length + ? `
${meta + .slice(0, 6) + .map( + ([k, v]) => + `
${esc(k)}${esc(v)}
`, + ) + .join('')}
` + : '' + } +
${esc(p.meta?.source || '')}${Number.isFinite(p.lat) ? ` · ${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}` : ''}
+
+ + + + +
`; + pick.hidden = false; + chrome.classList.add('has-pick'); + pick.animate( + [ + { transform: 'translateY(14px) scale(.97)', opacity: 0 }, + { transform: 'none', opacity: 1 }, + ], + { duration: 380, easing: 'cubic-bezier(.32,1.25,.4,1)' }, + ); + haptic('light'); + } + globe.on('pick', showPick); + pick.addEventListener('click', async (e) => { + if (e.target.closest('.pick-close')) { + globe.select(null); + return hidePick(); + } + const a = e.target.closest('[data-pick]')?.dataset.pick; + if (!a || !picked) return; + const p = picked; + haptic('light'); + if (a === 'follow') { + const on = store.toggleFollow(`contact:${p.id}`); + toast(on ? `Following ${p.title}` : 'Unfollowed', { + icon: icon(on ? 'starFill' : 'star'), + }); + showPick(p); + } else if (a === 'ride') { + globe.select(p.id); + await globe.flyTo({ + lon: p.lon, + lat: p.lat, + height: p.kind === 'satellite' ? 1200e3 : 6000, + pitch: -12, + duration: 2, + }); + if (globe.isStub) + toast('Ride view needs the live engine — following from above', { + icon: icon('ride'), + }); + } else if (a === 'eye') { + const cams = globe + .contactsNear(p.lon, p.lat, 60) + .filter((c) => /cam|cctv/i.test(c.kind)); + if (cams.length) { + globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 2500 }); + toast(`Nearest eye: ${cams[0].title}`); + } else toast('No public camera within 60 km', { icon: icon('video') }); + } else if (a === 'thread') + onThread?.({ + id: `contact:${p.id}`, + title: p.title, + place: p.subtitle, + lon: p.lon, + lat: p.lat, + family: KIND_FAM[p.kind] || 'infra', + }); + }); + + /* ---------- two-pane leading view (Globe tab) ---------- */ + function renderLeadView() { + const st = familyState(); + const active = store.get('lens') || 'cinematic'; + leadView.innerHTML = ` +

Globe

+
${Object.values(st).reduce((a, s) => a + s.on, 0)} lenses on · drag the planet, pinch to descend
+
Visual lens
+
${LENSES.map((l) => ``).join('')}
+ ${FAMILIES.map((f) => { + const s = st[f.id]; + return `
${icon(FAMILY_ICON[f.id])}${f.name}${s.on}/${s.total} · ${fmtNum(s.count)}
+
${s.layers.map(tileHtml).join('')}
`; + }).join('')} +

`; + } + leadView.addEventListener('click', (e) => { + const t = e.target.closest('[data-layer]'); + if (t) { + const on = t.getAttribute('aria-checked') !== 'true'; + haptic('tick'); + globe.setLayer(t.dataset.layer, on); + setTimeout(refreshDock, 30); + return; + } + const l = e.target.closest('[data-lens2]'); + if (l) { + setLens(l.dataset.lens2); + renderLeadView(); + } + if (e.target.closest('[data-act2="credits"]')) openCredits(); + }); + + refreshDock(); + + return { + ribbon, + refreshDock, + renderLeadView, + hidePick, + showPick, + openGrid, + openCredits, + setPips: (p) => ribbon.setPips(p), + }; +} diff --git a/mobile/ui/globeHost.js b/mobile/ui/globeHost.js new file mode 100644 index 000000000..ac1f016f6 --- /dev/null +++ b/mobile/ui/globeHost.js @@ -0,0 +1,171 @@ +/** + * Mounts the one persistent globe behind the shell. + * + * Prefers the real Cesium engine (mobile/globe/index.js, DESIGN.md §10) and + * falls back to the offline stub so the shell is always demoable. Every call + * goes through a guard so a missing method or a throwing engine never takes + * the shell down; mismatches are collected in `contractReport` for the lead. + */ + +const REQUIRED = [ + 'flyTo', + 'home', + 'layers', + 'setLayer', + 'setFamily', + 'setTime', + 'timeWindow', + 'setLens', + 'focus', + 'select', + 'on', + 'off', + 'pause', + 'resume', + 'contactsNear', + 'situations', +]; + +export const contractReport = { + engine: 'none', + missing: [], + errors: [], + notes: [], +}; +if (typeof window !== 'undefined') window.__gevContract = contractReport; + +/** Families are lower-case ids in the shell; the engine may capitalise them. */ +const fam = (f) => String(f || 'infra').toLowerCase(); +const normLayer = (l) => ({ ...l, family: fam(l.family) }); +function normSituation(s) { + const section = String(s.section || '').toLowerCase(); + return { + ...s, + family: fam(s.family), + section: + section === 'reconstruction' ? 'reconstructions' : section || undefined, + alwaysOn: s.alwaysOn ?? section === 'always', + }; +} + +function guard(engine, kind) { + const note = (m) => { + if (!contractReport.errors.includes(m)) contractReport.errors.push(m); + }; + const call = (name, fallback, ...args) => { + const fn = engine[name]; + if (typeof fn !== 'function') return fallback; + try { + return fn.apply(engine, args); + } catch (err) { + note(`${name}(): ${err?.message || err}`); + console.warn(`[globe:${kind}] ${name} failed`, err); + return fallback; + } + }; + const asyncCall = async (name, fallback, ...args) => { + try { + const v = await call(name, fallback, ...args); + return v ?? fallback; + } catch (err) { + note(`${name}(): ${err?.message || err}`); + return fallback; + } + }; + return { + kind, + raw: engine, + isStub: kind === 'stub', + flyTo: (o) => asyncCall('flyTo', undefined, o), + home: () => call('home', undefined), + layers: () => { + const v = call('layers', []); + return Array.isArray(v) ? v.map(normLayer) : []; + }, + setLayer: (id, on) => call('setLayer', undefined, id, on), + setFamily: (f, on) => call('setFamily', undefined, f, on), + setTime: (d) => call('setTime', undefined, d), + timeWindow: () => { + const v = call('timeWindow', null); + const n = Date.now(); + if (!v || !v.start || !v.end) + return { + start: new Date(n - 72 * 3600e3), + end: new Date(n + 120 * 3600e3), + now: new Date(n), + }; + return { + start: new Date(v.start), + end: new Date(v.end), + now: new Date(v.now ?? n), + }; + }, + setLens: (id) => call('setLens', undefined, id), + focus: (s) => asyncCall('focus', undefined, s), + select: (id) => call('select', undefined, id), + on: (ev, fn) => call('on', undefined, ev, fn), + off: (ev, fn) => call('off', undefined, ev, fn), + pause: () => call('pause', undefined), + resume: () => call('resume', undefined), + contactsNear: (lon, lat, km) => { + const v = call('contactsNear', [], lon, lat, km); + return Array.isArray(v) ? v : []; + }, + situations: async () => { + const v = await asyncCall('situations', []); + return Array.isArray(v) ? v.map(normSituation) : []; + }, + credits: () => { + const v = call('credits', null); + return Array.isArray(v) ? v : null; + }, + }; +} + +/** + * @param {HTMLElement} container + * @param {{ quality?: string, preferStub?: boolean }} opts + */ +export async function mountGlobe(container, opts = {}) { + const params = new URLSearchParams(location.search); + const forceStub = opts.preferStub || params.get('engine') === 'stub'; + if (!forceStub) { + try { + // A glob (not a bare dynamic import) so a checkout without the engine + // still builds; Vite re-evaluates it when mobile/globe/index.js appears. + const loaders = import.meta.glob('../globe/index.js'); + const load = loaders['../globe/index.js']; + if (!load) throw new Error('mobile/globe/index.js not present'); + const mod = await load(); + const create = mod.createGlobe || mod.default?.createGlobe || mod.default; + if (typeof create !== 'function') + throw new Error('mobile/globe/index.js exports no createGlobe'); + const quality = + opts.quality === 'battery' ? 'low' : opts.quality || 'auto'; + const engine = await create(container, { quality }); + contractReport.engine = 'cesium'; + contractReport.missing = REQUIRED.filter( + (m) => typeof engine?.[m] !== 'function', + ); + if (typeof engine?.credits !== 'function') + contractReport.notes.push( + 'credits() not provided (optional, used for attribution sheet)', + ); + if (contractReport.missing.length) + console.warn('[globe] contract gaps', contractReport.missing); + return guard(engine, 'cesium'); + } catch (err) { + contractReport.notes.push( + `real engine unavailable: ${err?.message || err}`, + ); + console.info('[globe] using offline stub:', err?.message || err); + container.replaceChildren(); + } + } + const { createGlobe } = await import('./globeStub.js'); + const engine = await createGlobe(container, { + quality: opts.quality || 'auto', + }); + contractReport.engine = 'stub'; + return guard(engine, 'stub'); +} diff --git a/mobile/ui/globeStub.js b/mobile/ui/globeStub.js new file mode 100644 index 000000000..78d569351 --- /dev/null +++ b/mobile/ui/globeStub.js @@ -0,0 +1,1855 @@ +/** + * Offline stand-in for mobile/globe (DESIGN.md §10 Globe API contract). + * + * A WebGL2 orthographic planet: Esri World Imagery day mosaic, NASA Black + * Marble night lights blended across the real terminator, an atmosphere rim, + * and a sharper Esri export for the view when you descend. Contacts and + * situations are SIMULATED so the shell is always demoable; every card and + * pick from this engine says so. + */ + +import { esriThumb } from './dom.js'; + +const R_EARTH = 6371008.8; +const D2R = Math.PI / 180; +const R2D = 180 / Math.PI; +const FOV_HALF = Math.tan(20 * D2R); +const MIN_H = 60e3; +const MAX_H = 42000e3; +const HOME = { lon: 20, lat: 18, height: 15500e3 }; + +const SIM = 'Simulated · offline stub engine'; + +const DAY_URL = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export?bbox=-180,-90,180,90&bboxSR=4326&imageSR=4326&size=4096,2048&format=jpg&f=image'; +const NIGHT_URL = + 'https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi?SERVICE=WMS&REQUEST=GetMap&VERSION=1.3.0&LAYERS=VIIRS_Black_Marble&STYLES=&CRS=EPSG:4326&BBOX=-90,-180,90,180&WIDTH=2048&HEIGHT=1024&FORMAT=image/jpeg'; + +/* ------------------------------------------------------------------ */ +/* Catalog (DESIGN.md §8) */ +/* ------------------------------------------------------------------ */ + +const CATALOG = { + sky: [ + 'Commercial flights', + 'Military aircraft', + 'Helicopters', + 'Private / GA', + 'Emergency squawks', + 'Drones (Remote ID)', + 'Airport boards', + 'Holding patterns', + ], + sea: [ + 'AIS vessels', + 'Ports', + 'Tankers / cargo', + 'Fishing effort', + 'Dark vessels', + 'Submarine cables', + ], + orbit: [ + 'Satellites (TLE)', + 'ISS & crewed', + 'Starlink trains', + 'Launches', + 'Debris', + 'Ground tracks', + ], + hazards: [ + 'Earthquakes', + 'Active fires', + 'Fire perimeters', + 'Floods', + 'Tropical cyclones', + 'Volcanoes', + 'Air quality', + ], + eyes: [ + 'Public CCTV', + 'Signed captures', + 'Street-level', + 'Holograms', + 'Live streams', + ], + ground: [ + 'Transit vehicles', + 'Traffic flow', + 'Bike share', + 'Rail', + 'Events & venues', + 'ALPR cameras', + ], + weather: ['Radar', 'Clouds', 'Lightning', 'Wind', 'Forecast models'], + infra: [ + 'Datacenters', + 'Dams', + 'Power plants', + 'Military installations', + 'GNSS interference', + 'Recent imagery', + ], +}; + +const slug = (s) => + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + +const DEFAULT_ON = new Set([ + 'sky.commercial-flights', + 'sky.helicopters', + 'sea.ais-vessels', + 'orbit.iss-crewed', + 'orbit.satellites-tle', + 'hazards.earthquakes', + 'hazards.active-fires', + 'hazards.tropical-cyclones', + 'hazards.floods', +]); + +/* ------------------------------------------------------------------ */ +/* Deterministic randomness */ +/* ------------------------------------------------------------------ */ + +function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/* ------------------------------------------------------------------ */ +/* Geometry */ +/* ------------------------------------------------------------------ */ + +function toVec(lon, lat) { + const cl = Math.cos(lat * D2R); + return [ + cl * Math.cos(lon * D2R), + cl * Math.sin(lon * D2R), + Math.sin(lat * D2R), + ]; +} +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +function haversine(lon1, lat1, lon2, lat2) { + const dLat = (lat2 - lat1) * D2R; + const dLon = (lon2 - lon1) * D2R; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * D2R) * Math.cos(lat2 * D2R) * Math.sin(dLon / 2) ** 2; + return 2 * R_EARTH * Math.asin(Math.min(1, Math.sqrt(a))); +} + +function destination(lon, lat, bearingDeg, distM) { + const d = distM / R_EARTH; + const b = bearingDeg * D2R; + const p1 = lat * D2R; + const l1 = lon * D2R; + const p2 = Math.asin( + Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b), + ); + const l2 = + l1 + + Math.atan2( + Math.sin(b) * Math.sin(d) * Math.cos(p1), + Math.cos(d) - Math.sin(p1) * Math.sin(p2), + ); + return [((l2 * R2D + 540) % 360) - 180, p2 * R2D]; +} + +function subsolar(date) { + const start = Date.UTC(date.getUTCFullYear(), 0, 0); + const doy = (date.getTime() - start) / 86400000; + const decl = 23.44 * Math.sin(((2 * Math.PI) / 365) * (doy - 81)); + const hours = + date.getUTCHours() + + date.getUTCMinutes() / 60 + + date.getUTCSeconds() / 3600; + const lon = -(hours - 12) * 15; + return toVec(lon, decl); +} + +/* ------------------------------------------------------------------ */ +/* Simulated world */ +/* ------------------------------------------------------------------ */ + +const HUBS = [ + [-84.43, 33.64], + [-87.9, 41.98], + [-0.45, 51.47], + [55.36, 25.25], + [139.78, 35.55], + [2.55, 49.01], + [8.57, 50.04], + [103.99, 1.36], + [-118.41, 33.94], + [-73.78, 40.64], + [-122.38, 37.62], + [77.1, 28.56], + [151.18, -33.94], + [-46.47, -23.43], + [28.75, 41.26], + [4.76, 52.31], + [126.45, 37.46], + [114.2, 22.3], + [-99.07, 19.44], + [18.6, -33.97], +]; +const PORTS = [ + [32.55, 29.9, 'Suez approach'], + [103.85, 1.22, 'Singapore Strait'], + [4.05, 51.95, 'Maasvlakte'], + [-122.5, 37.79, 'Golden Gate'], + [-5.6, 35.95, 'Gibraltar'], + [121.6, 31.2, 'Yangshan'], + [-79.7, 9.2, 'Panama'], + [56.3, 26.4, 'Hormuz'], +]; +const PREFIX = ['AEX', 'NVA', 'PLR', 'HLX', 'TRN', 'KTX', 'ORV', 'SNW']; +const SHIP = [ + 'Aster', + 'Nordlys', + 'Kestrel', + 'Meridian', + 'Halcyon', + 'Sable', + 'Tern', + 'Cobalt', + 'Brightwater', + 'Pelagia', +]; + +function buildContacts() { + const r = rng(7); + const out = []; + HUBS.forEach(([lon, lat], hi) => { + for (let i = 0; i < 34; i += 1) { + const [x, y] = destination(lon, lat, r() * 360, 20e3 + r() * 900e3); + out.push({ + kind: 'aircraft', + layer: 'sky.commercial-flights', + id: `ac-${hi}-${i}`, + title: `${PREFIX[(hi + i) % PREFIX.length]}${100 + Math.floor(r() * 8900)}`, + lon: x, + lat: y, + altM: 2500 + r() * 9800, + headingDeg: r() * 360, + speed: 170 + r() * 90, + }); + } + }); + for (let i = 0; i < 23; i += 1) { + const [x, y] = destination(-85.72, 31.36, r() * 360, 3e3 + r() * 18e3); + out.push({ + kind: 'helicopter', + layer: 'sky.helicopters', + id: `heli-${i}`, + title: `ARMY ${String(20000 + Math.floor(r() * 900))}`, + lon: x, + lat: y, + altM: 150 + r() * 300, + headingDeg: r() * 360, + speed: 45 + r() * 20, + circuit: true, + }); + } + for (let i = 0; i < 14; i += 1) { + const [x, y] = destination(3.5, 55.5, r() * 360, r() * 260e3); + out.push({ + kind: 'aircraft', + layer: 'sky.military-aircraft', + id: `mil-${i}`, + title: `RCH${300 + i}`, + lon: x, + lat: y, + altM: 6000 + r() * 4000, + headingDeg: r() * 360, + speed: 190, + }); + } + PORTS.forEach(([lon, lat], pi) => { + for (let i = 0; i < 26; i += 1) { + const [x, y] = destination(lon, lat, r() * 360, r() * 60e3); + out.push({ + kind: 'ship', + layer: 'sea.ais-vessels', + id: `ship-${pi}-${i}`, + title: `${SHIP[(pi * 3 + i) % SHIP.length]} ${['Star', 'Spirit', 'Trader', 'Bay', 'Venture', 'Dawn', 'Pioneer'][(i + pi) % 7]}${i >= 10 ? ` ${['II', 'III', 'IV'][i % 3]}` : ''}`, + lon: x, + lat: y, + altM: 0, + headingDeg: r() * 360, + speed: r() < 0.6 ? 0.3 : 6 + r() * 6, + }); + } + }); + const sats = [ + { + id: 'iss', + title: 'ISS (ZARYA)', + layer: 'orbit.iss-crewed', + inc: 51.6, + alt: 418e3, + raan: 40, + phase: 0, + }, + ]; + for (let i = 0; i < 48; i += 1) { + sats.push({ + id: `sat-${i}`, + title: i < 18 ? `STARLINK-${31000 + i * 7}` : `SAT-${40000 + i * 113}`, + layer: 'orbit.satellites-tle', + inc: i < 18 ? 53 : 45 + r() * 53, + alt: i < 18 ? 550e3 : 480e3 + r() * 800e3, + raan: i < 18 ? 120 : r() * 360, + phase: i < 18 ? i * 3.2 : r() * 360, + }); + } + sats.forEach((s) => + out.push({ + kind: 'satellite', + layer: s.layer, + id: s.id, + title: s.title, + orbit: s, + lon: 0, + lat: 0, + altM: s.alt, + headingDeg: 0, + }), + ); + const quakes = [ + [144.2, 42.1, 6.4], + [-70.9, -30.2, 5.1], + [27.8, 38.4, 4.6], + [-155.3, 19.4, 4.2], + [95.9, 2.9, 5.3], + ]; + quakes.forEach(([lon, lat, m], i) => + out.push({ + kind: 'quake', + layer: 'hazards.earthquakes', + id: `eq-${i}`, + title: `M${m.toFixed(1)}`, + lon, + lat, + altM: 0, + headingDeg: 0, + mag: m, + }), + ); + for (let i = 0; i < 40; i += 1) { + const [x, y] = destination(-120.9, 39.0, r() * 360, r() * 90e3); + out.push({ + kind: 'fire', + layer: 'hazards.active-fires', + id: `fire-${i}`, + title: 'FIRMS hotspot', + lon: x, + lat: y, + altM: 0, + headingDeg: 0, + }); + } + return out; +} + +function propagate(c, t0, t) { + if (c.kind === 'satellite') { + const o = c.orbit; + const mu = 3.986004418e14; + const a = R_EARTH + o.alt; + const n = Math.sqrt(mu / a ** 3); + const u = o.phase * D2R + n * (t / 1000); + const inc = o.inc * D2R; + const lat = Math.asin(Math.sin(inc) * Math.sin(u)); + const lonI = Math.atan2(Math.cos(inc) * Math.sin(u), Math.cos(u)); + const earth = 7.2921159e-5 * (t / 1000); + const lon = (((lonI + o.raan * D2R - earth) * R2D + 540) % 360) - 180; + c.lon = lon; + c.lat = lat * R2D; + c.headingDeg = 90 - o.inc * Math.cos(u); + return; + } + if (!c.speed) return; + const dt = (t - t0) / 1000; + if (!c.base) c.base = { lon: c.lon, lat: c.lat, h: c.headingDeg }; + if (c.circuit) { + const w = c.speed / 2500; + const h = c.base.h + ((dt * w * R2D) % 360); + const [x, y] = destination(c.base.lon, c.base.lat, h, 2500); + c.lon = x; + c.lat = y; + c.headingDeg = (h + 90) % 360; + return; + } + const [x, y] = destination(c.base.lon, c.base.lat, c.base.h, c.speed * dt); + c.lon = x; + c.lat = y; +} + +/* ------------------------------------------------------------------ */ +/* Situations */ +/* ------------------------------------------------------------------ */ + +function buildSituations(now) { + const min = 60e3; + const S = (o) => ({ + live: true, + source: SIM, + updatedAt: now - (o.age ?? 2) * min, + time: o.time ?? { start: now - 6 * 3600e3, end: now + 24 * 3600e3 }, + camera: o.camera ?? { + lon: o.lon, + lat: o.lat, + height: o.height ?? 400e3, + heading: 0, + pitch: -90, + }, + thumbnail: esriThumb(o.lon, o.lat, o.span ?? 1, 480, o.th ?? 600), + ...o, + }); + return [ + S({ + id: 'cyclone-wpac', + kind: 'cyclone', + family: 'hazards', + title: 'Typhoon tracking toward Luzon', + subtitle: 'Category 4 · eyewall replacement under way', + place: 'Philippine Sea', + lon: 126.5, + lat: 15.4, + span: 9, + th: 640, + height: 2200e3, + layers: ['hazards.tropical-cyclones', 'weather.clouds', 'weather.wind'], + metrics: [ + { label: 'Wind', value: 185, unit: 'km/h' }, + { label: 'Pressure', value: 935, unit: 'hPa' }, + { label: 'Moving', value: 'WNW 18', unit: 'km/h' }, + ], + series: [120, 130, 140, 150, 165, 175, 185, 185, 180, 185], + age: 2, + time: { start: now - 72 * 3600e3, end: now + 120 * 3600e3 }, + events: [{ at: now + 30 * 3600e3, label: 'Landfall (fcst)' }], + }), + S({ + id: 'quake-24h', + kind: 'quake', + family: 'hazards', + title: 'M6.4 · largest quake in 24 h', + subtitle: 'Shallow thrust event, aftershocks continuing', + place: 'Off Hokkaido, Japan', + lon: 144.2, + lat: 42.1, + span: 3.5, + th: 480, + height: 900e3, + layers: ['hazards.earthquakes'], + metrics: [ + { label: 'Magnitude', value: 6.4, unit: 'Mw' }, + { label: 'Depth', value: 38, unit: 'km' }, + { label: 'Aftershocks', value: 27, unit: '' }, + ], + series: [0, 0, 6.4, 4.1, 3.8, 4.4, 3.2, 3.9, 3.1, 3.3], + age: 11, + events: [{ at: now - 14 * 3600e3, label: 'M6.4' }], + }), + S({ + id: 'fires-sierra', + kind: 'fires', + family: 'hazards', + title: 'Fire cluster in the Sierra foothills', + subtitle: 'FIRMS hotspots doubling since dawn', + place: 'Nevada County, California', + lon: -120.9, + lat: 39.05, + span: 1.6, + th: 560, + height: 260e3, + layers: [ + 'hazards.active-fires', + 'hazards.fire-perimeters', + 'weather.wind', + ], + metrics: [ + { label: 'Hotspots', value: 214, unit: '' }, + { label: 'FRP', value: 1840, unit: 'MW' }, + { label: 'Wind', value: 32, unit: 'km/h' }, + ], + series: [12, 18, 30, 44, 61, 88, 120, 160, 190, 214], + age: 4, + }), + S({ + id: 'iss-pass', + kind: 'pass', + family: 'orbit', + title: 'ISS passes over you tonight', + subtitle: 'Bright pass · max elevation 71°', + place: 'Low Earth orbit', + lon: -40, + lat: 30, + span: 30, + th: 520, + height: 9000e3, + layers: ['orbit.iss-crewed', 'orbit.ground-tracks'], + metrics: [ + { label: 'Altitude', value: 418, unit: 'km' }, + { label: 'Speed', value: 27600, unit: 'km/h' }, + { label: 'Next pass', value: '21:42', unit: '' }, + ], + age: 1, + alwaysOn: true, + }), + S({ + id: 'atl-rush', + kind: 'airport', + family: 'sky', + title: 'Busiest airport right now', + subtitle: 'Hartsfield–Jackson arrival bank', + place: 'Atlanta, Georgia', + lon: -84.428, + lat: 33.6407, + span: 0.09, + th: 600, + height: 18e3, + layers: [ + 'sky.commercial-flights', + 'sky.holding-patterns', + 'sky.airport-boards', + ], + metrics: [ + { label: 'Movements', value: 182, unit: '/h' }, + { label: 'Holding', value: 6, unit: '' }, + { label: 'Delay', value: 14, unit: 'min' }, + ], + series: [120, 140, 166, 170, 182, 176, 181, 182], + age: 1, + alwaysOn: true, + }), + S({ + id: 'novosel', + kind: 'heliport', + family: 'sky', + title: 'Helicopter school circuits', + subtitle: 'Rotor training pattern over Wiregrass', + place: 'Fort Novosel, Alabama', + lon: -85.72, + lat: 31.36, + span: 0.22, + th: 420, + height: 40e3, + layers: ['sky.helicopters'], + metrics: [ + { label: 'Airborne', value: 23, unit: 'rotorcraft' }, + { label: 'Pattern alt', value: 700, unit: 'ft' }, + ], + series: [4, 9, 14, 18, 23, 21, 23], + age: 3, + alwaysOn: true, + }), + S({ + id: 'bhote-koshi-2026', + kind: 'reconstruction', + family: 'hazards', + title: 'Bhote Koshi outburst flood', + subtitle: '16 sourced observations · 3 open hypotheses', + place: 'Rasuwa, Nepal ↔ Gyirong, Tibet', + lon: 85.33, + lat: 28.15, + span: 0.3, + th: 640, + height: 30e3, + camera: { + lon: 85.3548, + lat: 28.207, + height: 14e3, + heading: 344, + pitch: -39, + }, + layers: [ + 'hazards.floods', + 'infra.recent-imagery', + 'eyes.signed-captures', + ], + metrics: [ + { label: 'Observations', value: 16, unit: '' }, + { label: 'Corridor', value: 145, unit: 'pts' }, + { label: 'Hypotheses', value: 3, unit: '' }, + ], + live: false, + age: 60 * 24 * 7, + source: 'GeoPera reconstruction · Vantor Open Data (CC BY-NC 4.0)', + time: { start: Date.UTC(2026, 7, 26, 0), end: Date.UTC(2026, 7, 27, 12) }, + thread: 'bhote-koshi-2026', + thumbnail: '/events/bhote-koshi-2026/post.webp', + }), + S({ + id: 'starbase', + kind: 'launch', + family: 'orbit', + title: 'Launch countdown · Starbase', + subtitle: 'Window opens 06:00 local · trajectory east', + place: 'Boca Chica, Texas', + lon: -97.157, + lat: 25.997, + span: 0.06, + th: 380, + height: 12e3, + layers: ['orbit.launches', 'orbit.ground-tracks'], + metrics: [ + { label: 'T−', value: '06:12:00', unit: '' }, + { label: 'Weather go', value: 80, unit: '%' }, + ], + age: 6, + events: [{ at: now + 6.2 * 3600e3, label: 'T-0' }], + }), + S({ + id: 'suez-queue', + kind: 'queue', + family: 'sea', + title: 'Ships queuing at Suez', + subtitle: 'Southbound convoy holding in the anchorage', + place: 'Gulf of Suez, Egypt', + lon: 32.55, + lat: 29.88, + span: 0.35, + th: 560, + height: 60e3, + layers: ['sea.ais-vessels', 'sea.tankers-cargo'], + metrics: [ + { label: 'Waiting', value: 41, unit: 'vessels' }, + { label: 'Median wait', value: 19, unit: 'h' }, + ], + series: [28, 30, 33, 35, 39, 41], + age: 5, + }), + S({ + id: 'gnss-baltic', + kind: 'gnss', + family: 'infra', + title: 'GNSS interference over the Baltic', + subtitle: 'Aircraft reporting degraded NIC', + place: 'Baltic Sea', + lon: 21.0, + lat: 56.5, + span: 6, + th: 480, + height: 1500e3, + layers: ['infra.gnss-interference', 'sky.commercial-flights'], + metrics: [ + { label: 'Affected', value: 312, unit: 'aircraft' }, + { label: 'Area', value: 84, unit: 'k km²' }, + ], + series: [120, 180, 240, 260, 300, 312], + age: 9, + }), + S({ + id: 'singapore', + kind: 'strait', + family: 'sea', + title: 'Singapore Strait traffic', + subtitle: 'Eastbound lane at capacity', + place: 'Singapore Strait', + lon: 103.85, + lat: 1.22, + span: 0.4, + th: 420, + height: 70e3, + layers: ['sea.ais-vessels'], + metrics: [ + { label: 'Transits', value: 22, unit: '/h' }, + { label: 'At anchor', value: 318, unit: '' }, + ], + age: 3, + alwaysOn: true, + }), + S({ + id: 'tokyo-rush', + kind: 'transit', + family: 'ground', + title: 'Rush hour · Tokyo rail', + subtitle: 'Yamanote line headways at 2 min 30 s', + place: 'Shinjuku, Tokyo', + lon: 139.7, + lat: 35.69, + span: 0.05, + th: 600, + height: 9e3, + layers: ['ground.transit-vehicles', 'ground.rail'], + metrics: [ + { label: 'Trains moving', value: 1840, unit: '' }, + { label: 'On time', value: 98.6, unit: '%' }, + ], + age: 2, + alwaysOn: true, + }), + S({ + id: 'rotterdam', + kind: 'port', + family: 'sea', + title: 'Port of Rotterdam', + subtitle: 'Europe’s busiest port, always on', + place: 'Maasvlakte, Netherlands', + lon: 4.03, + lat: 51.96, + span: 0.12, + th: 380, + height: 30e3, + layers: ['sea.ais-vessels', 'sea.ports'], + metrics: [ + { label: 'Berthed', value: 96, unit: '' }, + { label: 'Arriving 6 h', value: 34, unit: '' }, + ], + age: 4, + alwaysOn: true, + }), + S({ + id: 'north-sea-mil', + kind: 'military', + family: 'sky', + title: 'Military air activity · North Sea', + subtitle: 'Tanker tracks and fighter CAPs', + place: 'North Sea', + lon: 3.5, + lat: 55.5, + span: 5, + th: 520, + height: 900e3, + layers: ['sky.military-aircraft'], + metrics: [ + { label: 'Aircraft', value: 14, unit: '' }, + { label: 'Tankers', value: 3, unit: '' }, + ], + age: 7, + }), + S({ + id: 'aurora', + kind: 'aurora', + family: 'weather', + title: 'Aurora nowcast · Tromsø', + subtitle: 'Kp 6 storm, clear skies after 22:00', + place: 'Tromsø, Norway', + lon: 18.95, + lat: 69.65, + span: 0.5, + th: 600, + height: 300e3, + layers: ['weather.clouds'], + metrics: [ + { label: 'Kp', value: 6, unit: '' }, + { label: 'Cloud', value: 20, unit: '%' }, + ], + series: [2, 3, 3, 4, 5, 6, 6], + age: 15, + }), + S({ + id: 'sfo-arrivals', + kind: 'airport', + family: 'sky', + title: 'SFO arrivals on 28L/R', + subtitle: 'Paired approaches over the bay', + place: 'San Francisco, California', + lon: -122.379, + lat: 37.6213, + span: 0.12, + th: 480, + height: 25e3, + layers: ['sky.commercial-flights', 'sky.airport-boards'], + metrics: [ + { label: 'Arrivals', value: 44, unit: '/h' }, + { label: 'Delay', value: 9, unit: 'min' }, + ], + age: 2, + }), + S({ + id: 'etna', + kind: 'volcano', + family: 'eyes', + title: 'Etna webcam vs ash advisory', + subtitle: 'Strombolian activity at the SE crater', + place: 'Sicily, Italy', + lon: 14.995, + lat: 37.751, + span: 0.2, + th: 420, + height: 30e3, + layers: ['hazards.volcanoes', 'eyes.public-cctv'], + metrics: [ + { label: 'Cameras', value: 4, unit: 'live' }, + { label: 'Ash FL', value: 120, unit: '' }, + ], + age: 13, + }), + S({ + id: 'dxb', + kind: 'airport', + family: 'sky', + title: 'Dubai International night bank', + subtitle: 'Long-haul departures to Europe', + place: 'Dubai, UAE', + lon: 55.3657, + lat: 25.2532, + span: 0.1, + th: 560, + height: 25e3, + layers: ['sky.commercial-flights'], + metrics: [{ label: 'Departures', value: 61, unit: '/h' }], + age: 1, + alwaysOn: true, + }), + ]; +} + +/* ------------------------------------------------------------------ */ +/* WebGL planet */ +/* ------------------------------------------------------------------ */ + +const VS = `#version 300 es +in vec2 p; void main(){ gl_Position = vec4(p,0.,1.); }`; + +const FS = `#version 300 es +precision highp float; +uniform vec2 u_c; uniform float u_R; uniform vec2 u_res; +uniform vec3 u_right, u_up, u_fwd, u_sun; +uniform sampler2D u_day, u_night, u_det; +uniform vec4 u_box; uniform float u_detOn, u_tex, u_lens, u_nightTex, u_close; +out vec4 o; +const float PI = 3.14159265; +float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } +vec4 sampleEq(sampler2D t, vec2 uv){ + vec2 uv2 = vec2(fract(uv.x+0.5), uv.y); + vec2 dx1 = dFdx(uv), dy1 = dFdy(uv), dx2 = dFdx(uv2), dy2 = dFdy(uv2); + vec2 dx = abs(dx1.x) < abs(dx2.x) ? dx1 : dx2; + vec2 dy = abs(dy1.x) < abs(dy2.x) ? dy1 : dy2; + return textureGrad(t, uv, dx, dy); +} +void main(){ + vec2 f = gl_FragCoord.xy; + vec2 p = (f - u_c) / u_R; + float r2 = dot(p,p); + vec3 col; + if (r2 > 1.0) { + float r = sqrt(r2); + float k = (r - 1.0) * u_R / (u_R * 0.045 + 5.0); + float halo = exp(-k * 1.6); + vec3 dirW = normalize(p.x*u_right + p.y*u_up); + float sunSide = 0.25 + 0.75*smoothstep(-0.5, 0.7, dot(dirW, u_sun)); + vec3 atm = mix(vec3(0.18,0.36,0.85), vec3(0.55,0.78,1.0), halo) * halo * sunSide; + vec2 g = floor(f / 1.5); + float st = step(0.9975, hash(g)) * (0.4 + 0.6*hash(g+7.7)); + vec2 q = f / u_res; + vec3 neb = vec3(0.02,0.03,0.06) * (0.6 + 0.4*sin(q.x*3.0+q.y*2.0)); + col = vec3(0.008,0.012,0.022) + neb + atm*0.95 + vec3(st)*(1.0-halo)*0.85; + } else { + float z = sqrt(1.0 - r2); + vec3 n = normalize(p.x*u_right + p.y*u_up + z*u_fwd); + float lat = asin(clamp(n.z,-1.,1.)); + float lon = atan(n.y, n.x); + vec2 uv = vec2((lon + PI) / (2.0*PI), (PI*0.5 - lat) / PI); + vec3 day; + if (u_tex > 0.5) { + day = sampleEq(u_day, uv).rgb; + float polar = smoothstep(84.0, 85.0, abs(lat*180.0/PI)); + vec3 pole = lat > 0.0 ? vec3(0.78,0.84,0.9) : vec3(0.9,0.93,0.96); + day = mix(day, pole, polar); + } else { + day = mix(vec3(0.03,0.10,0.22), vec3(0.08,0.22,0.38), 0.5 + 0.5*sin(lat*3.0)); + } + if (u_detOn > 0.001) { + float lonD = lon*180.0/PI, latD = lat*180.0/PI; + vec2 duv = vec2((lonD - u_box.x)/(u_box.z - u_box.x), (u_box.w - latD)/(u_box.w - u_box.y)); + if (duv.x > 0.0 && duv.x < 1.0 && duv.y > 0.0 && duv.y < 1.0) { + vec2 e = min(duv, 1.0-duv); + float edge = smoothstep(0.0, 0.06, min(e.x, e.y)); + day = mix(day, texture(u_det, duv).rgb, edge * u_detOn); + } + } + vec3 nightL = u_nightTex > 0.5 ? sampleEq(u_night, uv).rgb : vec3(0.0); + float mu = dot(n, u_sun); + float dayK = smoothstep(-0.10, 0.16, mu); + if (u_lens > 1.5 && u_lens < 2.5) dayK *= 0.0; + vec3 lit = day * (0.18 + 1.05*clamp(mu*1.4+0.2, 0.0, 1.0)); + vec3 nightC = pow(nightL, vec3(1.9)) * vec3(1.65,1.25,0.78) * 2.2 * (1.0 - u_close) + day * mix(0.035, 0.3, u_close); + col = mix(nightC, lit, dayK); + col += vec3(1.0,0.42,0.14) * 0.16 * exp(-pow(mu/0.07, 2.0)) * (1.0 - u_lens*0.0); + float rim = pow(1.0 - z, 2.6); + col = mix(col, vec3(0.42,0.68,1.0) * (0.25 + 0.9*dayK), rim * 0.62); + } + // Lens grade: 0 cinematic, 1 clear, 2 night, 3 thermal, 4 nvg + vec2 q = f / u_res; + if (u_lens < 0.5) { + col = pow(col, vec3(0.95)) * vec3(1.02,1.0,1.04); + float v = smoothstep(1.25, 0.35, length(q - 0.5) * 1.35); + col *= mix(0.62, 1.0, v); + } else if (u_lens > 2.5 && u_lens < 3.5) { + float l = dot(col, vec3(0.299,0.587,0.114)); + col = clamp(vec3(l*2.6-0.2, l*1.6-0.25, sin(l*6.28)*0.5+0.1 - l*0.4), 0.0, 1.0); + } else if (u_lens > 3.5) { + float l = dot(col, vec3(0.299,0.587,0.114)); + float gr = hash(f + fract(u_res.x*0.37)) * 0.08; + col = vec3(0.12, 1.0, 0.38) * (pow(l, 0.7)*1.4 + gr); + float v = smoothstep(0.75, 0.3, length(q - 0.5)); + col *= v; + } else if (u_lens > 1.5) { + col *= vec3(0.9, 0.95, 1.1); + } + col = col / (1.0 + col * 0.12); + o = vec4(col, 1.0); +}`; + +function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +} + +/* ------------------------------------------------------------------ */ +/* createGlobe */ +/* ------------------------------------------------------------------ */ + +export async function createGlobe(container, options = {}) { + const listeners = new Map(); + const emit = (ev, data) => listeners.get(ev)?.forEach((fn) => fn(data)); + + const root = document.createElement('div'); + root.className = 'stub-globe'; + root.style.cssText = + 'position:absolute;inset:0;overflow:hidden;touch-action:none;background:#05070b'; + const canvas = document.createElement('canvas'); + const overlay = document.createElement('canvas'); + for (const c of [canvas, overlay]) + c.style.cssText = + 'position:absolute;inset:0;width:100%;height:100%;display:block'; + overlay.style.pointerEvents = 'none'; + root.append(canvas, overlay); + container.appendChild(root); + + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + powerPreference: 'high-performance', + preserveDrawingBuffer: false, + }); + const ctx = overlay.getContext('2d'); + + const quality = options.quality || 'auto'; + let dpr = Math.min( + window.devicePixelRatio || 1, + quality === 'battery' ? 1.25 : 2, + ); + + const cam = { ...HOME, heading: 0 }; + let W = 1; + let H = 1; + let paused = false; + let dirty = true; + let lens = 0; + let timeOverride = null; + let selected = null; + let flight = null; + const t0 = Date.now(); + const contacts = buildContacts(); + const layers = []; + for (const [fam, names] of Object.entries(CATALOG)) + for (const name of names) { + const id = `${fam}.${slug(name)}`; + layers.push({ + id, + family: fam, + name, + on: DEFAULT_ON.has(id), + count: 0, + status: DEFAULT_ON.has(id) ? 'live' : 'off', + }); + } + const layerById = new Map(layers.map((l) => [l.id, l])); + const counts = {}; + for (const c of contacts) counts[c.layer] = (counts[c.layer] || 0) + 1; + const r = rng(99); + for (const l of layers) { + l.count = counts[l.id] ?? Math.floor(5 + r() * 900); + if (r() < 0.06 && !l.on) l.status = 'off'; + } + layerById.get('ground.alpr-cameras').status = 'off'; + let situations = null; + + /* ---------- GL ---------- */ + let prog = null; + let U = {}; + const tex = { day: null, night: null, det: null }; + let texOn = 0; + let nightOn = 0; + const det = { on: 0, target: 0, box: [0, 0, 0, 0], key: '', pending: null }; + + function makeTex(unit, img) { + const t = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, t); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + if (img) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); + } else { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGB, + 1, + 1, + 0, + gl.RGB, + gl.UNSIGNED_BYTE, + new Uint8Array([10, 30, 60]), + ); + } + const ext = gl.getExtension('EXT_texture_filter_anisotropic'); + if (ext) gl.texParameterf(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, 8); + return t; + } + + if (gl) { + const sh = (type, src) => { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) + throw new Error(gl.getShaderInfoLog(s)); + return s; + }; + prog = gl.createProgram(); + gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); + gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS)); + gl.linkProgram(prog); + gl.useProgram(prog); + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + const loc = gl.getAttribLocation(prog, 'p'); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); + for (const n of [ + 'u_c', + 'u_R', + 'u_res', + 'u_right', + 'u_up', + 'u_fwd', + 'u_sun', + 'u_day', + 'u_night', + 'u_det', + 'u_box', + 'u_detOn', + 'u_tex', + 'u_lens', + 'u_nightTex', + 'u_close', + ]) + U[n] = gl.getUniformLocation(prog, n); + tex.day = makeTex(0); + tex.night = makeTex(1); + tex.det = makeTex(2); + gl.uniform1i(U.u_day, 0); + gl.uniform1i(U.u_night, 1); + gl.uniform1i(U.u_det, 2); + loadImage(DAY_URL) + .then((img) => { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex.day); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); + texOn = 1; + dirty = true; + emit('ready', { imagery: 'esri' }); + }) + .catch(() => + emit('quality', { scale: dpr, fps: 0, note: 'imagery offline' }), + ); + loadImage(NIGHT_URL) + .then((img) => { + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, tex.night); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); + nightOn = 1; + dirty = true; + }) + .catch(() => {}); + } else { + root.style.background = + 'radial-gradient(circle at 42% 38%, #3d7fbf 0, #123a66 30%, #071526 46%, #05070b 47%)'; + } + + function resize() { + const rect = root.getBoundingClientRect(); + W = Math.max(1, rect.width); + H = Math.max(1, rect.height); + for (const c of [canvas, overlay]) { + c.width = Math.round(W * dpr); + c.height = Math.round(H * dpr); + } + dirty = true; + } + const ro = new ResizeObserver(resize); + ro.observe(root); + resize(); + + /* ---------- camera math ---------- */ + const radiusPx = (height = cam.height) => { + const d = R_EARTH + height; + const tanT = R_EARTH / Math.sqrt(d * d - R_EARTH * R_EARTH); + return (tanT / FOV_HALF) * (Math.min(W, H) / 2) * 1.05; + }; + function basis() { + const lo = cam.lon * D2R; + const la = cam.lat * D2R; + const fwd = toVec(cam.lon, cam.lat); + const e = [-Math.sin(lo), Math.cos(lo), 0]; + const nn = [ + -Math.sin(la) * Math.cos(lo), + -Math.sin(la) * Math.sin(lo), + Math.cos(la), + ]; + const h = (cam.heading || 0) * D2R; + const ch = Math.cos(h); + const s = Math.sin(h); + const up = [ + nn[0] * ch + e[0] * s, + nn[1] * ch + e[1] * s, + nn[2] * ch + e[2] * s, + ]; + const right = [ + e[0] * ch - nn[0] * s, + e[1] * ch - nn[1] * s, + e[2] * ch - nn[2] * s, + ]; + return { fwd, up, right }; + } + let B = basis(); + const centerY = () => H * (options.centerBias ?? 0.5); + function project(lon, lat, altM = 0) { + const v = toVec(lon, lat); + const z = dot(v, B.fwd); + const k = 1 + altM / R_EARTH; + if (z < (altM > 0 ? -0.15 : 0.02)) return null; + const R = radiusPx(); + return { + x: W / 2 + R * k * dot(v, B.right), + y: centerY() - R * k * dot(v, B.up), + z, + }; + } + function unproject(x, y) { + const R = radiusPx(); + const px = (x - W / 2) / R; + const py = (centerY() - y) / R; + const r2 = px * px + py * py; + if (r2 > 1) return null; + const z = Math.sqrt(1 - r2); + const n = [0, 1, 2].map( + (i) => px * B.right[i] + py * B.up[i] + z * B.fwd[i], + ); + return { lon: Math.atan2(n[1], n[0]) * R2D, lat: Math.asin(n[2]) * R2D }; + } + + const now = () => (timeOverride ? timeOverride.getTime() : Date.now()); + + /* ---------- detail imagery ---------- */ + function updateDetail() { + if (!gl || cam.height > 3200e3) { + det.target = 0; + return; + } + const R = radiusPx(); + const halfW = Math.min(80, (W / 2 / R) * R2D * 1.25); + const halfH = Math.min(60, (H / 2 / R) * R2D * 1.25); + const clat = Math.cos(cam.lat * D2R); + const box = [ + cam.lon - halfW / Math.max(0.15, clat), + Math.max(-85, cam.lat - halfH), + cam.lon + halfW / Math.max(0.15, clat), + Math.min(85, cam.lat + halfH), + ]; + const key = box.map((v) => v.toFixed(2)).join(','); + if ( + det.on > 0.5 && + det.box[0] <= box[0] && + det.box[2] >= box[2] && + det.box[1] <= box[1] && + det.box[3] >= box[3] && + det.box[2] - det.box[0] < (box[2] - box[0]) * 3 + ) + return; + if (key === det.key) return; + det.key = key; + clearTimeout(det.pending); + det.pending = setTimeout(() => { + const pad = 0.15; + const bw = box[2] - box[0]; + const bh = box[3] - box[1]; + const b = [ + box[0] - bw * pad, + Math.max(-85, box[1] - bh * pad), + box[2] + bw * pad, + Math.min(85, box[3] + bh * pad), + ]; + const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export?bbox=${b.map((v) => v.toFixed(5)).join(',')}&bboxSR=4326&imageSR=4326&size=1536,1536&format=jpg&f=image`; + loadImage(url) + .then((img) => { + if (det.key !== key) return; + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, tex.det); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGB, + gl.RGB, + gl.UNSIGNED_BYTE, + img, + ); + gl.generateMipmap(gl.TEXTURE_2D); + det.box = b; + det.on = 0; + det.target = 1; + dirty = true; + }) + .catch(() => {}); + }, 380); + } + + /* ---------- render loop ---------- */ + let raf = 0; + let lastOverlay = 0; + let frames = 0; + let fpsT = performance.now(); + function frame(ts) { + raf = 0; + if (paused) return; + if (flight) stepFlight(ts); + if (inertia) stepInertia(); + if (det.on !== det.target) { + det.on += (det.target - det.on) * 0.12; + if (Math.abs(det.on - det.target) < 0.01) det.on = det.target; + dirty = true; + } + if (dirty && gl) { + B = basis(); + gl.viewport(0, 0, canvas.width, canvas.height); + const R = radiusPx() * dpr; + gl.uniform2f(U.u_c, (W / 2) * dpr, (H - centerY()) * dpr); + gl.uniform1f(U.u_R, R); + gl.uniform2f(U.u_res, canvas.width, canvas.height); + gl.uniform3fv(U.u_right, B.right); + gl.uniform3fv(U.u_up, B.up); + gl.uniform3fv(U.u_fwd, B.fwd); + gl.uniform3fv(U.u_sun, subsolar(new Date(now()))); + gl.uniform4fv(U.u_box, det.box); + gl.uniform1f(U.u_detOn, det.on); + gl.uniform1f(U.u_tex, texOn); + gl.uniform1f(U.u_nightTex, nightOn); + gl.uniform1f(U.u_lens, lens); + // Close in, the 2 km/px night mosaic is mush: fade it for moonlit imagery. + gl.uniform1f( + U.u_close, + Math.max(0, Math.min(1, (900e3 - cam.height) / 700e3)), + ); + gl.drawArrays(gl.TRIANGLES, 0, 3); + dirty = false; + } else if (dirty) { + B = basis(); + dirty = false; + } + if (ts - lastOverlay > 33) { + lastOverlay = ts; + drawOverlay(ts); + } + frames += 1; + if (ts - fpsT > 2000) { + emit('quality', { + scale: dpr, + fps: Math.round((frames * 1000) / (ts - fpsT)), + }); + frames = 0; + fpsT = ts; + } + raf = requestAnimationFrame(frame); + } + const kick = () => { + if (!raf && !paused) raf = requestAnimationFrame(frame); + }; + + const KIND_COLOR = { + aircraft: '#7CC4FF', + helicopter: '#7CC4FF', + ship: '#38E1C6', + satellite: '#C9A7FF', + quake: '#FF6A3D', + fire: '#FF6A3D', + }; + let drawn = []; + function drawOverlay(ts) { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + drawn = []; + const t = now(); + const planetary = cam.height > 2500e3; + for (const c of contacts) { + const layer = layerById.get(c.layer); + if (!layer?.on) continue; + propagate(c, t0, t); + const p = project(c.lon, c.lat, c.kind === 'satellite' ? c.altM : 0); + if (!p || p.x < -20 || p.y < -20 || p.x > W + 20 || p.y > H + 20) + continue; + const col = KIND_COLOR[c.kind] || '#fff'; + const isSel = selected === c.id; + ctx.globalAlpha = planetary ? 0.75 : 1; + if (c.kind === 'aircraft' || c.kind === 'helicopter') { + if (planetary) { + ctx.fillStyle = col; + ctx.fillRect(p.x - 0.8, p.y - 0.8, 1.6, 1.6); + } else { + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(((c.headingDeg - (cam.heading || 0)) * Math.PI) / 180); + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(0, -6); + ctx.lineTo(4.2, 5); + ctx.lineTo(0, 2.6); + ctx.lineTo(-4.2, 5); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + } else if (c.kind === 'satellite') { + ctx.fillStyle = c.id === 'iss' ? '#fff' : col; + const s = c.id === 'iss' ? 3.2 : 1.8; + ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s); + if (c.id === 'iss') { + ctx.strokeStyle = col; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(p.x, p.y, 6 + Math.sin(ts / 300) * 1.2, 0, Math.PI * 2); + ctx.stroke(); + } + } else if (c.kind === 'quake') { + const rr = 3 + (c.mag - 4) * 3.5; + ctx.strokeStyle = col; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(p.x, p.y, rr + ((ts / 40) % 14), 0, Math.PI * 2); + ctx.globalAlpha = 1 - ((ts / 40) % 14) / 14; + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(p.x, p.y, rr * 0.45, 0, Math.PI * 2); + ctx.fill(); + } else if (c.kind === 'fire') { + ctx.fillStyle = 'rgba(255,106,61,0.85)'; + ctx.beginPath(); + ctx.arc(p.x, p.y, planetary ? 1.4 : 2.6, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(p.x, p.y, planetary ? 1 : 2.4, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + if (isSel) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(p.x, p.y, 13, 0, Math.PI * 2); + ctx.stroke(); + if (c.speed) { + ctx.strokeStyle = col; + ctx.setLineDash([2, 3]); + ctx.beginPath(); + for (let i = 0; i <= 20; i += 1) { + const [x, y] = destination( + c.lon, + c.lat, + c.headingDeg + 180, + c.speed * i * 30, + ); + const q = project(x, y); + if (!q) break; + if (i) ctx.lineTo(q.x, q.y); + else ctx.moveTo(q.x, q.y); + } + ctx.stroke(); + ctx.setLineDash([]); + } + } + drawn.push({ c, x: p.x, y: p.y }); + } + if (situations && options.showSituations !== false) { + for (const s of situations) { + const p = project(s.lon, s.lat); + if (!p) continue; + const col = { + sky: '#7CC4FF', + sea: '#38E1C6', + orbit: '#C9A7FF', + hazards: '#FF6A3D', + eyes: '#FFD166', + ground: '#9BE15D', + weather: '#8AB4F8', + infra: '#B8C0CC', + }[s.family]; + const ph = (((ts / 1600 + s.lon) % 1) + 1) % 1; + ctx.strokeStyle = col; + ctx.globalAlpha = 1 - ph; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(p.x, p.y, 5 + ph * 16, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(5,7,11,0.8)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + drawn.push({ s, x: p.x, y: p.y }); + } + } + } + + /* ---------- flights ---------- */ + function stepFlight(ts) { + const f = flight; + if (!f.start) f.start = ts; + const k = Math.min(1, (ts - f.start) / f.dur); + const e = k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2; + let dLon = f.to.lon - f.from.lon; + if (dLon > 180) dLon -= 360; + if (dLon < -180) dLon += 360; + cam.lon = ((f.from.lon + dLon * e + 540) % 360) - 180; + cam.lat = f.from.lat + (f.to.lat - f.from.lat) * e; + const lh0 = Math.log(f.from.height); + const lh1 = Math.log(f.to.height); + const hop = Math.log(Math.max(f.from.height, f.to.height, f.peak)); + const lin = lh0 + (lh1 - lh0) * e; + const arc = Math.sin(Math.PI * e) * Math.max(0, hop - Math.max(lh0, lh1)); + cam.height = Math.exp(lin + arc); + cam.heading = f.from.heading + (f.to.heading - f.from.heading) * e; + dirty = true; + emitCamera(); + if (k >= 1) { + flight = null; + updateDetail(); + f.resolve(); + } + } + + function flyTo({ lon, lat, height, duration } = {}) { + if (flight) flight.resolve(); + inertia = null; + const to = { + lon: lon ?? cam.lon, + lat: Math.max(-85, Math.min(85, lat ?? cam.lat)), + height: Math.max(MIN_H, Math.min(MAX_H, height ?? cam.height)), + // The stub renders top-down; heading is honoured by the real engine. + heading: 0, + }; + const dist = haversine(cam.lon, cam.lat, to.lon, to.lat); + const dur = (duration ?? Math.min(3.2, 1.2 + dist / 6e6)) * 1000; + return new Promise((resolve) => { + if (dur <= 16) { + Object.assign(cam, to); + dirty = true; + emitCamera(); + updateDetail(); + kick(); + resolve(); + return; + } + flight = { + from: { ...cam }, + to, + dur, + peak: Math.min(MAX_H, dist * 1.4), + start: 0, + resolve, + }; + det.target = cam.height > 3200e3 ? 0 : det.target; + kick(); + }); + } + + let camTimer = 0; + function emitCamera() { + if (camTimer) return; + camTimer = setTimeout(() => { + camTimer = 0; + emit('camera', { lon: cam.lon, lat: cam.lat, height: cam.height }); + }, 80); + } + + /* ---------- input ---------- */ + const pointers = new Map(); + let inertia = null; + let lastMove = null; + let pinch0 = null; + let downAt = null; + function onDown(e) { + root.setPointerCapture?.(e.pointerId); + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + flight = null; + inertia = null; + downAt = { x: e.clientX, y: e.clientY, t: performance.now(), moved: false }; + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + pinch0 = { d: Math.hypot(a.x - b.x, a.y - b.y), h: cam.height }; + } + emit('interact', { type: 'start' }); + } + function onMove(e) { + const prev = pointers.get(e.pointerId); + if (!prev) return; + const cur = { x: e.clientX, y: e.clientY }; + pointers.set(e.pointerId, cur); + if (downAt && Math.hypot(cur.x - downAt.x, cur.y - downAt.y) > 6) + downAt.moved = true; + if (pointers.size === 2 && pinch0) { + const [a, b] = [...pointers.values()]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + cam.height = Math.max( + MIN_H, + Math.min(MAX_H, (pinch0.h * pinch0.d) / Math.max(10, d)), + ); + dirty = true; + emitCamera(); + kick(); + return; + } + const dx = cur.x - prev.x; + const dy = cur.y - prev.y; + pan(dx, dy); + const t = performance.now(); + lastMove = { dx, dy, t }; + } + function pan(dx, dy) { + const R = radiusPx(); + const clat = Math.max(0.1, Math.cos(cam.lat * D2R)); + cam.lon = ((cam.lon - ((dx / R) * R2D) / clat + 540) % 360) - 180; + cam.lat = Math.max(-85, Math.min(85, cam.lat + (dy / R) * R2D)); + dirty = true; + emitCamera(); + kick(); + } + function onUp(e) { + pointers.delete(e.pointerId); + if (pointers.size < 2) pinch0 = null; + if (pointers.size) return; + if (downAt && !downAt.moved && performance.now() - downAt.t < 400) { + const rect = root.getBoundingClientRect(); + tapAt(e.clientX - rect.left, e.clientY - rect.top); + } else if (lastMove && performance.now() - lastMove.t < 60) { + inertia = { vx: lastMove.dx, vy: lastMove.dy }; + kick(); + } + updateDetail(); + emit('interact', { type: 'end' }); + downAt = null; + } + function stepInertia() { + inertia.vx *= 0.92; + inertia.vy *= 0.92; + pan(inertia.vx, inertia.vy); + if (Math.hypot(inertia.vx, inertia.vy) < 0.15) { + inertia = null; + updateDetail(); + } + } + function tapAt(x, y) { + let best = null; + let bd = 24; + for (const d of drawn) { + const dd = Math.hypot(d.x - x, d.y - y); + if (dd < bd) { + bd = dd; + best = d; + } + } + if (!best) { + select(null); + emit('pick', null); + return; + } + if (best.s) { + const s = best.s; + emit('pick', { + kind: 'situation', + id: s.id, + title: s.title, + subtitle: s.place, + lon: s.lon, + lat: s.lat, + meta: { situation: s.id, source: s.source }, + }); + return; + } + const c = best.c; + select(c.id); + const meta = {}; + if (c.altM) + meta.altitude = + c.kind === 'satellite' + ? `${Math.round(c.altM / 1000)} km` + : `${Math.round(c.altM * 3.28084).toLocaleString()} ft`; + if (c.speed) + meta.speed = + c.kind === 'ship' + ? `${(c.speed * 1.944).toFixed(1)} kn` + : `${Math.round(c.speed * 1.944)} kn`; + if (c.speed || c.kind === 'satellite') + meta.heading = `${Math.round(((c.headingDeg % 360) + 360) % 360)}°`; + meta.source = SIM; + const sub = { + aircraft: 'Aircraft', + helicopter: 'Helicopter', + ship: 'Vessel', + satellite: 'Satellite', + quake: 'Earthquake', + fire: 'Active fire', + }[c.kind]; + emit('pick', { + kind: c.kind, + id: c.id, + title: c.title, + subtitle: `${sub} · simulated`, + lon: c.lon, + lat: c.lat, + meta, + }); + } + function onWheel(e) { + e.preventDefault(); + cam.height = Math.max( + MIN_H, + Math.min(MAX_H, cam.height * Math.exp(e.deltaY * 0.0018)), + ); + dirty = true; + emitCamera(); + kick(); + clearTimeout(onWheel.t); + onWheel.t = setTimeout(updateDetail, 200); + } + root.addEventListener('pointerdown', onDown); + root.addEventListener('pointermove', onMove, { passive: true }); + root.addEventListener('pointerup', onUp); + root.addEventListener('pointercancel', onUp); + root.addEventListener('wheel', onWheel, { passive: false }); + root.addEventListener('dblclick', (e) => { + const rect = root.getBoundingClientRect(); + const g = unproject(e.clientX - rect.left, e.clientY - rect.top); + if (g) flyTo({ ...g, height: cam.height / 2.5, duration: 0.6 }); + }); + + /* ---------- API ---------- */ + function select(id) { + selected = id; + kick(); + } + function setLayer(id, on) { + const l = layerById.get(id); + if (!l) return; + l.on = !!on; + l.status = on ? 'live' : 'off'; + emit('layers', layers); + kick(); + } + const localSeeded = new Set(); + const api = { + isStub: true, + flyTo, + home: () => flyTo({ ...HOME }), + layers: () => layers.map((l) => ({ ...l })), + setLayer, + setFamily(family, on) { + for (const l of layers) + if (l.family === family) + ((l.on = !!on), (l.status = on ? 'live' : 'off')); + emit('layers', layers); + kick(); + }, + setTime(date) { + timeOverride = date ? new Date(date) : null; + dirty = true; + kick(); + emit('time', { time: timeOverride }); + }, + timeWindow() { + const n = Date.now(); + return { + start: new Date(n - 72 * 3600e3), + end: new Date(n + 120 * 3600e3), + now: new Date(n), + }; + }, + setLens(id) { + lens = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 }[id] ?? 0; + dirty = true; + kick(); + }, + async focus(s) { + if (!s) return; + for (const id of s.layers || []) + if (layerById.has(id)) setLayer(id, true); + if (s.live) api.setTime(null); + else if (s.time?.end) api.setTime(new Date(s.time.end)); + const c = s.camera || { lon: s.lon, lat: s.lat, height: 400e3 }; + return flyTo({ + lon: c.lon, + lat: c.lat, + height: c.height, + heading: c.heading, + duration: options.reducedMotion ? 0 : undefined, + }); + }, + select, + on(ev, fn) { + if (!listeners.has(ev)) listeners.set(ev, new Set()); + listeners.get(ev).add(fn); + }, + off(ev, fn) { + listeners.get(ev)?.delete(fn); + }, + pause() { + paused = true; + if (raf) cancelAnimationFrame(raf); + raf = 0; + }, + resume() { + paused = false; + dirty = true; + kick(); + }, + contactsNear(lon, lat, km) { + const key = `${lon.toFixed(1)},${lat.toFixed(1)}`; + if (!localSeeded.has(key)) { + localSeeded.add(key); + const rr = rng(Math.floor((lon + 180) * 100 + (lat + 90) * 7)); + for (let i = 0; i < 14; i += 1) { + const [x, y] = destination(lon, lat, rr() * 360, 4e3 + rr() * 70e3); + contacts.push({ + kind: 'aircraft', + layer: 'sky.commercial-flights', + id: `local-${key}-${i}`, + title: `${PREFIX[i % PREFIX.length]}${200 + Math.floor(rr() * 7000)}`, + lon: x, + lat: y, + altM: 900 + rr() * 11000, + headingDeg: rr() * 360, + speed: 120 + rr() * 120, + }); + } + for (let i = 0; i < 3; i += 1) { + const [x, y] = destination(lon, lat, rr() * 360, 3e3 + rr() * 20e3); + contacts.push({ + kind: 'ship', + layer: 'sea.ais-vessels', + id: `localship-${key}-${i}`, + title: `${SHIP[i]} ${['Star', 'Bay', 'Trader'][i]}`, + lon: x, + lat: y, + altM: 0, + headingDeg: rr() * 360, + speed: 4, + }); + } + } + const t = now(); + const out = []; + for (const c of contacts) { + propagate(c, t0, t); + const d = haversine(lon, lat, c.lon, c.lat); + const lim = + c.kind === 'satellite' ? Math.max(km * 1000, 2200e3) : km * 1000; + if (d <= lim) + out.push({ + kind: c.kind === 'helicopter' ? 'aircraft' : c.kind, + id: c.id, + title: c.title, + lon: c.lon, + lat: c.lat, + altM: c.altM, + headingDeg: c.headingDeg, + }); + } + return out; + }, + async situations() { + if (!situations) situations = buildSituations(Date.now()); + kick(); + return situations; + }, + credits() { + return [ + { + name: 'Esri World Imagery', + text: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community', + url: 'https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9', + }, + { + name: 'NASA Black Marble', + text: 'NASA Earth Observatory / GIBS, VIIRS Black Marble', + url: 'https://earthdata.nasa.gov/gibs', + }, + { + name: 'Simulated contacts', + text: 'Offline stub engine. Aircraft, ships, satellites and situations here are simulated, not live.', + }, + ]; + }, + camera: () => ({ ...cam }), + destroy() { + api.pause(); + ro.disconnect(); + root.remove(); + }, + }; + kick(); + setTimeout( + () => + emit('camera', { + lon: cam.lon, + lat: cam.lat, + height: cam.height, + heading: 0, + pitch: -90, + }), + 0, + ); + return api; +} diff --git a/mobile/ui/icons.js b/mobile/ui/icons.js new file mode 100644 index 000000000..fc46de443 --- /dev/null +++ b/mobile/ui/icons.js @@ -0,0 +1,122 @@ +/** + * Hand-drawn SF Symbols–style glyphs on a 24-unit grid. + * Stroke 1.8, round caps/joins; `.f` parts fill when the glyph is "active". + * No icon fonts: the repo's Material Symbols font is subset. + */ + +const F = 'fill="currentColor" stroke="none"'; + +/** Rounded seal outline (checkmark.seal) generated from a wavy circle. */ +function sealPath(cx = 12, cy = 12, r = 8.6, bumps = 12, amp = 1.05) { + const steps = bumps * 8; + let d = ''; + for (let i = 0; i <= steps; i += 1) { + const a = (i / steps) * Math.PI * 2; + const rr = r + amp * Math.cos(a * bumps); + const x = cx + rr * Math.cos(a); + const y = cy + rr * Math.sin(a); + d += `${i ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`; + } + return `${d}Z`; +} + +const SEAL = sealPath(); + +const P = { + now: ``, + globe: ``, + viewfinder: ``, + threads: ``, + you: ``, + search: ``, + chevronLeft: ``, + chevronRight: ``, + chevronDown: ``, + chevronUp: ``, + close: ``, + info: ``, + north: ``, + home: ``, + layers: ``, + sky: ``, + sea: ``, + orbit: ``, + hazards: ``, + flame: ``, + eyes: ``, + ground: ``, + weather: ``, + infra: ``, + star: ``, + starFill: ``, + ride: ``, + video: ``, + share: ``, + seal: ``, + sealFill: ``, + play: ``, + pause: ``, + sparkles: ``, + agree: ``, + dispute: ``, + sliders: ``, + refresh: ``, + lock: ``, + key: ``, + location: ``, + camera: ``, + cube: ``, + download: ``, + lens: ``, + checkmark: ``, + xmark: ``, + minus: ``, + plus: ``, + ellipsis: ``, + clock: ``, + pin: ``, + sidebar: ``, + waveform: ``, + arrowUpRight: ``, + scope: ``, + hand: ``, +}; + +/** + * Inline SVG markup for a glyph. + * @param {string} name + * @param {{ label?: string, cls?: string }} [opts] + */ +export function icon(name, { label = '', cls = '' } = {}) { + const body = P[name] || P.ellipsis; + const a11y = label + ? `role="img" aria-label="${label}"` + : 'aria-hidden="true" focusable="false"'; + return `${body}`; +} + +export const FAMILY_ICON = { + sky: 'sky', + sea: 'sea', + orbit: 'orbit', + hazards: 'hazards', + eyes: 'eyes', + ground: 'ground', + weather: 'weather', + infra: 'infra', +}; + +export const FAMILIES = [ + { id: 'sky', name: 'Sky', color: '#7CC4FF' }, + { id: 'sea', name: 'Sea', color: '#38E1C6' }, + { id: 'orbit', name: 'Orbit', color: '#C9A7FF' }, + { id: 'hazards', name: 'Hazards', color: '#FF6A3D' }, + { id: 'eyes', name: 'Eyes', color: '#FFD166' }, + { id: 'ground', name: 'Ground', color: '#9BE15D' }, + { id: 'weather', name: 'Weather', color: '#8AB4F8' }, + { id: 'infra', name: 'Infra', color: '#B8C0CC' }, +]; + +export const FAMILY_COLOR = Object.fromEntries( + FAMILIES.map((f) => [f.id, f.color]), +); diff --git a/mobile/ui/inspector.js b/mobile/ui/inspector.js new file mode 100644 index 000000000..4e9f2d2e7 --- /dev/null +++ b/mobile/ui/inspector.js @@ -0,0 +1,114 @@ +/** Verify inspector: recompute and show every check, Tufte-clean. */ + +import { icon } from './icons.js'; +import { esc, sparkPath, esriThumb, haptic } from './dom.js'; +import { createSheet } from './sheet.js'; +import { verify } from '../capture/provenance.js'; +import { get } from '../capture/db.js'; + +const GLYPH = { + pass: 'checkmark', + warn: 'hazards', + fail: 'xmark', + na: 'minus', +}; + +export function createInspector() { + const sheet = createSheet({ + label: 'Verify capture', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'opaque inspector', + heights: { large: (H) => Math.round(H * 0.94) }, + }); + let urls = []; + + function small(label, values, unit) { + const v = values.filter(Number.isFinite); + if (v.length < 3) + return `
${label}—
`; + const lo = Math.min(...v); + const hi = Math.max(...v); + return `
${label}${lo.toFixed(1)}–${hi.toFixed(1)}${unit}
`; + } + + async function open(bundle) { + urls.forEach((u) => URL.revokeObjectURL(u)); + urls = []; + const rec = await get('media', bundle.id).catch(() => null); + const blob = rec?.blob || null; + const url = blob ? URL.createObjectURL(blob) : ''; + if (url) urls.push(url); + const isVideo = /video/.test(bundle.media.type); + const when = new Date(bundle.sealedAt); + sheet.head.innerHTML = `
+
${url ? (isVideo ? `` : `Capture ${bundle.seq}`) : ''}${icon('sealFill')}
+

Capture #${bundle.seq}

${esc(when.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' }))}${bundle.context?.testFrame ? ' · Test frame' : ''}
+
`; + sheet.body.innerHTML = `
Recomputing hashes and checking the signature…
`; + sheet.open('large'); + const t0 = performance.now(); + const res = await verify(bundle, blob); + const ms = Math.round(performance.now() - t0); + const fails = res.checks.filter((c) => c.status === 'fail').length; + const warns = res.checks.filter((c) => c.status === 'warn').length; + haptic(fails ? 'warn' : 'success'); + const imu = bundle.session?.imu || []; + const gps = bundle.session?.gps || []; + const f = gps[gps.length - 1]; + sheet.body.innerHTML = ` +
${icon(fails ? 'xmark' : 'seal')}
${fails ? `${fails} check${fails > 1 ? 's' : ''} failed` : warns ? `Verified with ${warns} caveat${warns > 1 ? 's' : ''}` : 'All checks passed'}Recomputed on this device in ${ms} ms. None of this proves truth; it raises the cost of lying.
+
    ${res.checks + .map( + (c) => + `
  1. ${icon(GLYPH[c.status] || 'minus')}
    ${esc(c.label)}${esc(c.detail)}
  2. `, + ) + .join('')}
+
Motion during capture${imu.length} samples
+
${small( + 'α', + imu.map((s) => s.α), + '°', + )}${small( + 'β', + imu.map((s) => s.β), + '°', + )}${small( + 'γ', + imu.map((s) => s.γ), + '°', + )}${small( + '|a|', + imu.map((s) => Math.hypot(s.ax ?? NaN, s.ay ?? NaN, s.az ?? NaN)), + '', + )}
+ ${f ? `
Where it was sealed±${Math.round(f.acc)} m
${f.lat.toFixed(5)}, ${f.lon.toFixed(5)}
` : ''} +
Bundle
+
hash
${esc(bundle.hash)}
+
media
${esc(bundle.media.sha256)}
+
prev
${esc(bundle.prev || 'null (genesis)')}
+
key
${esc(bundle.keyId)} · P-256
+
sig
${esc(bundle.signature.slice(0, 48))}…
+
device
${esc(bundle.context?.device || '')} · ${esc(bundle.context?.timezone || '')}
+
+

In a native build the key lives in the Secure Enclave / StrongBox with App Attest / Play Integrity, and the bundle is written as a C2PA manifest.

`; + sheet.body.querySelector('[data-insp="export"]').onclick = () => { + const a = document.createElement('a'); + const u = URL.createObjectURL( + new Blob([JSON.stringify(bundle, null, 2)], { + type: 'application/json', + }), + ); + a.href = u; + a.download = `${bundle.id}.bundle.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(u), 2000); + }; + sheet.body.querySelector('[data-insp="reverify"]').onclick = () => + open(bundle); + } + + return { open, sheet }; +} diff --git a/mobile/ui/narrative.js b/mobile/ui/narrative.js new file mode 100644 index 000000000..9a677de8d --- /dev/null +++ b/mobile/ui/narrative.js @@ -0,0 +1,142 @@ +/** + * Narrative player: flies the globe through a thread's stops while the time + * ribbon scrubs the thread's own (schematic) clock. + */ + +import { icon } from './icons.js'; +import { h, haptic, reducedMotion } from './dom.js'; + +export function createNarrative({ app, chrome, globe, ribbon, getSituation }) { + const hud = + h(`
+
+
+
+
+
+
`); + chrome.appendChild(hud); + const playBtn = hud.querySelector('.narr-play'); + let run = null; + + function fmt(base) { + return (ms) => { + const s = Math.max(0, Math.round((ms - base) / 1000)); + return `T+${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; + }; + } + + function sleep(ms, r) { + return new Promise((resolve) => { + const t0 = performance.now(); + const tick = () => { + if (r.stopped) return resolve(); + if (r.paused) { + r.resumeAt = () => tick(); + return; + } + if (performance.now() - t0 >= ms) return resolve(); + requestAnimationFrame(tick); + }; + tick(); + }); + } + + async function play(thread) { + stop(); + const r = { stopped: false, paused: false, thread }; + run = r; + const base = Date.parse( + `${thread.observedDate || new Date().toISOString().slice(0, 10)}T00:00:00Z`, + ); + const dur = (thread.elapsedSeconds || 600) * 1000; + app.classList.add('is-world', 'is-narrative'); + globe.resume(); + ribbon.setWindow({ + start: base, + end: base + dur, + label: 'schematic clock', + format: fmt(base), + }); + hud.querySelector('.narr-prog').innerHTML = thread.steps + .map(() => '') + .join(''); + hud.querySelector('.narr-cav').textContent = thread.label || ''; + const sit = getSituation(thread.id); + if (sit) globe.focus(sit); + haptic('light'); + let t = base; + for (let i = 0; i < thread.steps.length && !r.stopped; i += 1) { + const s = thread.steps[i]; + hud.querySelector('.narr-step').textContent = + `Stop ${i + 1} of ${thread.steps.length} · ${s.phase || ''}`; + hud.querySelector('.narr-name').textContent = s.title; + hud + .querySelectorAll('.narr-prog i') + .forEach((el, k) => el.classList.toggle('done', k <= i)); + const target = + base + (s.progress ?? i / Math.max(1, thread.steps.length - 1)) * dur; + const flight = globe.flyTo({ + lon: s.lon, + lat: s.lat, + height: (s.elev || 1500) + 9000, + heading: 344, + pitch: -38, + duration: reducedMotion() ? 0 : 2.2, + }); + // Scrub the ribbon toward this stop's schematic time. + const from = t; + const t0 = performance.now(); + const scrub = () => { + if (r.stopped) return; + const k = Math.min(1, (performance.now() - t0) / 2000); + t = from + (target - from) * (1 - (1 - k) ** 3); + ribbon.seek(t); + globe.setTime(new Date(t)); + if (k < 1) requestAnimationFrame(scrub); + }; + scrub(); + await Promise.race([flight, sleep(2600, r)]); + await sleep(reducedMotion() ? 1200 : 1600, r); + } + if (!r.stopped) { + hud.querySelector('.narr-step').textContent = 'End of narrative'; + playBtn.innerHTML = icon('play'); + playBtn.setAttribute('aria-label', 'Replay'); + r.done = true; + } + } + + function stop() { + if (!run) return; + run.stopped = true; + run = null; + app.classList.remove('is-narrative'); + if (!app.classList.contains('keep-world')) app.classList.remove('is-world'); + ribbon.setWindow(null); + globe.setTime(null); + playBtn.innerHTML = icon('pause'); + } + + playBtn.addEventListener('click', () => { + if (!run) return; + if (run.done) return play(run.thread); + run.paused = !run.paused; + playBtn.innerHTML = icon(run.paused ? 'play' : 'pause'); + playBtn.setAttribute('aria-label', run.paused ? 'Play' : 'Pause'); + if (!run.paused && run.resumeAt) { + const f = run.resumeAt; + run.resumeAt = null; + f(); + } + }); + hud.querySelector('.pick-close').addEventListener('click', stop); + + return { + play, + stop, + get active() { + return !!run; + }, + }; +} diff --git a/mobile/ui/search.js b/mobile/ui/search.js new file mode 100644 index 000000000..501439668 --- /dev/null +++ b/mobile/ui/search.js @@ -0,0 +1,139 @@ +/** Search: situations, layers and places (OpenStreetMap Nominatim via /api/geocode). */ + +import { icon, FAMILY_ICON } from './icons.js'; +import { $, esc, haptic } from './dom.js'; +import { createSheet } from './sheet.js'; + +const PLACES = [ + { name: 'Kathmandu', sub: 'Nepal', lon: 85.324, lat: 27.7172 }, + { name: 'Strait of Hormuz', sub: 'Persian Gulf', lon: 56.4, lat: 26.5 }, + { name: 'Panama Canal', sub: 'Panama', lon: -79.68, lat: 9.08 }, + { name: 'Heathrow', sub: 'London, UK', lon: -0.4543, lat: 51.47 }, + { name: 'Cape Canaveral', sub: 'Florida, US', lon: -80.6, lat: 28.47 }, + { name: 'Mount Etna', sub: 'Sicily, Italy', lon: 14.995, lat: 37.751 }, +]; + +export function createSearch({ + getSituations, + globe, + onOpenSituation, + onPlace, + onLayer, +}) { + const sheet = createSheet({ + label: 'Search', + detents: ['large'], + modal: true, + dismissible: true, + className: 'search-sheet', + heights: { large: (H) => Math.round(H * 0.92) }, + }); + sheet.head.innerHTML = ``; + const input = $('input', sheet.head); + let timer = 0; + let geo = []; + + function match(q, ...fields) { + const s = fields.join(' ').toLowerCase(); + return q + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((w) => s.includes(w)); + } + + async function render() { + const q = input.value.trim(); + const sits = await getSituations(); + const s = q + ? sits.filter((x) => match(q, x.title, x.place, x.kind, x.family)) + : sits.slice(0, 5); + const layers = q + ? globe + .layers() + .filter((l) => match(q, l.name, l.family)) + .slice(0, 8) + : []; + const places = q + ? PLACES.filter((p) => match(q, p.name, p.sub)) + : PLACES.slice(0, 4); + sheet.body.innerHTML = ` +
${q ? 'Situations' : 'Live now'}
+ ${s.map((x) => ``).join('') || '

No situations match.

'} + ${layers.length ? `
Lenses
${layers.map((l) => ``).join('')}` : ''} +
Places
+ ${[...places, ...geo].map((p, i) => ``).join('')} + ${q.length >= 3 ? '

Press search to look up places · © OpenStreetMap contributors

' : ''}`; + sheet.body._places = [...places, ...geo]; + } + + async function geocode() { + const q = input.value.trim(); + if (q.length < 3) return; + try { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) throw new Error(String(r.status)); + const j = await r.json(); + geo = (j.results || []) + .slice(0, 5) + .map((x) => ({ + name: x.formatted_address?.split(',')[0] || x.name || q, + sub: x.formatted_address || '', + lat: Number(x.geometry?.location?.lat ?? x.lat), + lon: Number(x.geometry?.location?.lng ?? x.lon), + })) + .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); + } catch { + geo = []; + } + render(); + } + + input.addEventListener('input', () => { + geo = []; + clearTimeout(timer); + timer = setTimeout(render, 80); + }); + sheet.head.querySelector('form').addEventListener('submit', (e) => { + e.preventDefault(); + geocode(); + }); + sheet.head + .querySelector('.search-cancel') + .addEventListener('click', () => sheet.close()); + + sheet.body.addEventListener('click', async (e) => { + const sit = e.target.closest('[data-sit]'); + if (sit) { + const s = (await getSituations()).find( + (x) => String(x.id) === sit.dataset.sit, + ); + sheet.close(); + if (s) setTimeout(() => onOpenSituation(s), 200); + return; + } + const l = e.target.closest('[data-layer]'); + if (l) { + haptic('tick'); + sheet.close(); + onLayer(l.dataset.layer); + return; + } + const p = e.target.closest('[data-place]'); + if (p) { + const place = sheet.body._places[Number(p.dataset.place)]; + sheet.close(); + if (place) onPlace(place); + } + }); + + return { + open() { + input.value = ''; + geo = []; + render(); + sheet.open('large'); + setTimeout(() => input.focus({ preventScroll: true }), 120); + }, + }; +} diff --git a/mobile/ui/sheet.js b/mobile/ui/sheet.js new file mode 100644 index 000000000..1cd29a175 --- /dev/null +++ b/mobile/ui/sheet.js @@ -0,0 +1,292 @@ +/** + * Bottom sheet with detents (peek / medium / large), velocity-aware drag, + * rubber-banding and nested scroll hand-off, after UISheetPresentationController. + */ + +import { h, clamp, emitter, haptic, reducedMotion } from './dom.js'; +import { icon } from './icons.js'; + +const DEFAULT_DETENTS = { + peek: () => 108, + medium: (H) => Math.round(H * 0.46), + large: (H) => Math.round(H * 0.92), +}; + +/** + * @param {{ + * host?: HTMLElement, label: string, detents?: string[], initial?: string, + * modal?: boolean, dismissible?: boolean, className?: string, + * heights?: Recordnumber>, closeButton?: boolean, + * }} o + */ +export function createSheet(o) { + const ev = emitter(); + const detents = o.detents || ['peek', 'medium', 'large']; + const heights = { ...DEFAULT_DETENTS, ...(o.heights || {}) }; + const host = o.host || document.getElementById('sheets'); + + const root = h(``); + host.appendChild(root); + const sheet = root.querySelector('.sheet'); + const body = root.querySelector('.sheet-body'); + const head = root.querySelector('.sheet-head'); + const scrim = root.querySelector('.sheet-scrim'); + + let H = 0; + let current = null; + let y = 0; // translateY from the top of the large position + let isOpen = false; + + const hostHeight = () => host.clientHeight || window.innerHeight; + const maxH = () => heights[detents[detents.length - 1]](H); + const offsetFor = (name) => maxH() - heights[name](H); + + function measure() { + H = hostHeight(); + sheet.style.height = `${maxH()}px`; + } + + function apply(animate, spring = true) { + sheet.style.transition = animate + ? `transform ${reducedMotion() ? 160 : 520}ms ${spring ? 'var(--spring)' : 'var(--ease-out)'}` + : 'none'; + sheet.style.transform = `translate3d(0, ${y}px, 0)`; + if (scrim) { + const lowest = offsetFor(detents[0]); + const k = clamp(1 - y / Math.max(1, lowest + 120), 0, 1); + scrim.style.transition = animate ? 'opacity 300ms linear' : 'none'; + scrim.style.opacity = String(isOpen ? 0.5 * k + 0.12 : 0); + } + } + + function setDetent(name, { animate = true, silent = false } = {}) { + if (!detents.includes(name)) return; + const changed = name !== current; + current = name; + y = offsetFor(name); + root.dataset.detent = name; + sheet.dataset.detent = name; + body.classList.toggle('scrollable', name === detents[detents.length - 1]); + if (name !== detents[detents.length - 1]) body.scrollTop = 0; + apply(animate); + if (changed && !silent) { + haptic('tick'); + ev.emit('detent', name); + } + } + + function open(name = o.initial || detents[Math.min(1, detents.length - 1)]) { + measure(); + root.hidden = false; + isOpen = true; + if (!current || root.dataset.closing) { + y = maxH() + 40; + apply(false); + void sheet.offsetHeight; + } + delete root.dataset.closing; + setDetent(name, { silent: false }); + ev.emit('open', name); + if (o.modal) + setTimeout( + () => + ( + sheet.querySelector('[autofocus]') || + root.querySelector('.sheet-grabber') + )?.focus({ preventScroll: true }), + 60, + ); + } + + function close() { + if (!isOpen) return; + isOpen = false; + root.dataset.closing = '1'; + y = maxH() + 40; + apply(true, false); + current = null; + ev.emit('close'); + setTimeout(() => { + if (!isOpen) root.hidden = true; + }, 420); + } + + /* ---------- drag ---------- */ + let drag = null; + const rubber = (over, dim) => (1 - 1 / ((over * 0.55) / dim + 1)) * dim; + + function bounded(raw) { + const top = 0; + const bottom = offsetFor(detents[0]); + if (raw < top) return top - rubber(top - raw, H); + if (raw > bottom && !o.dismissible) return bottom + rubber(raw - bottom, H); + return raw; + } + + function start(clientY) { + drag = { + y0: clientY, + start: y, + samples: [{ t: performance.now(), y: clientY }], + }; + sheet.style.transition = 'none'; + root.classList.add('dragging'); + } + function move(clientY) { + if (!drag) return; + const raw = drag.start + (clientY - drag.y0); + y = bounded(raw); + drag.samples.push({ t: performance.now(), y: clientY }); + if (drag.samples.length > 6) drag.samples.shift(); + apply(false); + } + function end() { + if (!drag) return; + root.classList.remove('dragging'); + const s = drag.samples; + const a = s[0]; + const b = s[s.length - 1]; + const v = b.t - a.t > 0 ? ((b.y - a.y) / (b.t - a.t)) * 1000 : 0; // px/s + drag = null; + const projected = y + v * 0.2; + const lowest = offsetFor(detents[0]); + if ( + o.dismissible && + (projected > lowest + 90 || (y > lowest + 40 && v > 300)) + ) { + close(); + return; + } + let best = detents[0]; + let bd = Infinity; + for (const d of detents) { + const dd = Math.abs(offsetFor(d) - projected); + if (dd < bd) { + bd = dd; + best = d; + } + } + // A decisive flick always moves at least one detent. + const idx = detents.indexOf(current); + if (best === current && Math.abs(v) > 700) { + const next = clamp(idx + (v < 0 ? 1 : -1), 0, detents.length - 1); + best = detents[next]; + } + setDetent(best); + if (best === current) apply(true); + } + + // Touch: hand off between the sheet and its scrolling body. + let touchMode = null; // 'sheet' | 'scroll' + let ty0 = 0; + sheet.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) return; + if (e.target.closest('input, textarea, [data-no-drag]')) { + touchMode = 'scroll'; + return; + } + ty0 = e.touches[0].clientY; + touchMode = null; + }, + { passive: true }, + ); + sheet.addEventListener( + 'touchmove', + (e) => { + if (e.touches.length !== 1 || touchMode === 'scroll') return; + const cy = e.touches[0].clientY; + if (!touchMode) { + const dy = cy - ty0; + if (Math.abs(dy) < 4) return; + const inBody = body.contains(e.target); + const atLarge = current === detents[detents.length - 1]; + if (inBody && atLarge && (dy < 0 || body.scrollTop > 0)) { + touchMode = 'scroll'; + return; + } + if (e.target.closest('[data-hscroll]') && Math.abs(dy) < 8) return; + touchMode = 'sheet'; + start(ty0); + } + if (touchMode === 'sheet') { + e.preventDefault(); + move(cy); + } + }, + { passive: false }, + ); + const tend = () => { + if (touchMode === 'sheet') end(); + touchMode = null; + }; + sheet.addEventListener('touchend', tend, { passive: true }); + sheet.addEventListener('touchcancel', tend, { passive: true }); + + // Mouse / pen: drag from the grabber or header area. + sheet.addEventListener('pointerdown', (e) => { + if (e.pointerType === 'touch' || e.button !== 0) return; + if (!e.target.closest('.sheet-grabber, .sheet-head, .sheet-drag')) return; + e.preventDefault(); + start(e.clientY); + const mv = (ev2) => move(ev2.clientY); + const up = () => { + window.removeEventListener('pointermove', mv); + window.removeEventListener('pointerup', up); + end(); + }; + window.addEventListener('pointermove', mv, { passive: true }); + window.addEventListener('pointerup', up); + }); + + root.querySelector('.sheet-grabber').addEventListener('click', () => { + const i = detents.indexOf(current); + setDetent(detents[(i + 1) % detents.length]); + }); + root.querySelector('.sheet-close')?.addEventListener('click', () => close()); + scrim?.addEventListener('click', () => close()); + root.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && (o.modal || o.dismissible)) { + e.stopPropagation(); + close(); + } + }); + + new ResizeObserver(() => { + if (!isOpen) return; + measure(); + if (current) { + y = offsetFor(current); + apply(false); + } + }).observe(host); + + return { + root, + sheet, + head, + body, + open, + close, + setDetent, + get detent() { + return current; + }, + get isOpen() { + return isOpen; + }, + on: ev.on, + off: ev.off, + /** Visible height of the sheet at its current position. */ + visibleHeight: () => Math.max(0, maxH() - y), + }; +} diff --git a/mobile/ui/tabbar.js b/mobile/ui/tabbar.js new file mode 100644 index 000000000..e740db0e7 --- /dev/null +++ b/mobile/ui/tabbar.js @@ -0,0 +1,153 @@ +/** + * iOS 26-style floating glass tab bar with a tab-bar accessory + * ("now playing" for the planet) and a search button in the same row. + * Minimises to a compact pill while the feed scrolls down. + */ + +import { icon, FAMILY_ICON } from './icons.js'; +import { $, $$, esc, ago, fmtNum, haptic } from './dom.js'; + +export const TABS = [ + { id: 'now', label: 'Now', icon: 'now' }, + { id: 'globe', label: 'Globe', icon: 'globe' }, + { id: 'capture', label: 'Capture', icon: 'viewfinder' }, + { id: 'threads', label: 'Threads', icon: 'threads' }, + { id: 'you', label: 'You', icon: 'you' }, +]; + +export function createTabBar(nav, { onSelect, onSearch, onAccessory }) { + nav.innerHTML = ` +
+ +
+ + ${TABS.map( + ( + t, + ) => ``, + ).join('')} +
+ +
+ `; + + const lens = $('.tb-lens', nav); + const tabsEl = $('.tb-tabs', nav); + const acc = $('.tb-accessory', nav); + const miniTab = $('.tb-mini-tab', nav); + const miniAcc = $('.tb-mini-acc', nav); + let current = 'now'; + let minimized = false; + let accSituation = null; + + function placeLens() { + const idx = TABS.findIndex((t) => t.id === current); + const w = (tabsEl.clientWidth - 8) / TABS.length; + lens.style.width = `${w}px`; + lens.style.transform = `translateX(${idx * w}px)`; + } + + function select(id, { silent = false } = {}) { + current = id; + $$('.tb-tab', nav).forEach((b) => + b.setAttribute('aria-selected', String(b.dataset.tab === id)), + ); + const t = TABS.find((x) => x.id === id); + miniTab.innerHTML = icon(t.icon); + placeLens(); + if (!silent) setMinimized(false); + } + + function setMinimized(v) { + if (v === minimized) return; + minimized = v; + nav.classList.toggle('min', v); + $('.tb-full', nav).toggleAttribute('inert', v); + $('.tb-mini', nav).setAttribute('aria-hidden', String(!v)); + $$('.tb-mini button', nav).forEach((b) => (b.tabIndex = v ? 0 : -1)); + } + + function accessoryHtml(s) { + const m = s.metrics?.[0]; + return `${icon(FAMILY_ICON[s.family] || 'now')} + ${esc(s.title)} + ${esc(s.place || '')} · ${esc(ago(s.updatedAt))} + ${m ? `${esc(fmtNum(m.value))}${m.unit ? ` ${esc(m.unit)}` : ''}` : ''}`; + } + + function setAccessory(s) { + accSituation = s; + acc.hidden = !s; + miniAcc.hidden = !s; + nav.classList.toggle('has-accessory', !!s); + document.documentElement.classList.toggle('has-accessory', !!s); + if (!s) return; + acc.dataset.family = s.family; + miniAcc.dataset.family = s.family; + acc.innerHTML = accessoryHtml(s); + miniAcc.innerHTML = accessoryHtml(s); + acc.setAttribute('aria-label', `Following ${s.title}`); + } + + nav.addEventListener('click', (e) => { + const tab = e.target.closest('.tb-tab'); + if (tab) { + if (tab.dataset.tab !== current) haptic('tick'); + onSelect(tab.dataset.tab, { reselect: tab.dataset.tab === current }); + return; + } + if (e.target.closest('.tb-search')) return onSearch(); + if (e.target.closest('.tb-accessory, .tb-mini-acc')) + return accSituation && onAccessory(accSituation); + if (e.target.closest('.tb-mini-tab')) setMinimized(false); + }); + + // Arrow-key roving focus across the tablist (ARIA tabs pattern). + tabsEl.addEventListener('keydown', (e) => { + if (!['ArrowLeft', 'ArrowRight'].includes(e.key)) return; + const tabs = $$('.tb-tab', nav); + const i = tabs.indexOf(document.activeElement); + const n = + tabs[(i + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length]; + n.focus(); + }); + + new ResizeObserver(placeLens).observe(tabsEl); + + /** Minimise while scrolling down; restore on scroll up or when scrolling stops. */ + function bindScroll(scroller) { + let lastY = scroller.scrollTop; + let acc = 0; + let idle = 0; + scroller.addEventListener( + 'scroll', + () => { + const y = scroller.scrollTop; + const dy = y - lastY; + lastY = y; + acc = Math.sign(dy) === Math.sign(acc) ? acc + dy : dy; + if (y < 40) setMinimized(false); + else if (acc > 28) setMinimized(true); + else if (acc < -18) setMinimized(false); + clearTimeout(idle); + idle = setTimeout(() => setMinimized(false), 1400); + }, + { passive: true }, + ); + } + + return { + select, + setMinimized, + setAccessory, + bindScroll, + get current() { + return current; + }, + }; +} diff --git a/mobile/ui/threads.js b/mobile/ui/threads.js new file mode 100644 index 000000000..e634f18f8 --- /dev/null +++ b/mobile/ui/threads.js @@ -0,0 +1,769 @@ +/** + * Threads: reconstructions people build together, always in three strata — + * Observations (immutable, sourced, hashed) → Hypotheses (person / AI, + * confidence, citations, agree / dispute) → Narrative (a camera path through + * space and time). Every change lands in a hash-chained ledger. + */ + +import { icon } from './icons.js'; +import { + $, + $$, + esc, + ago, + haptic, + toast, + fmtCoord, + reducedMotion, +} from './dom.js'; +import { canonical, sha256Hex } from '../capture/provenance.js'; +import { loadEventPack } from './world.js'; + +const LOCAL_KEY = 'gev.mobile.threads.local.v1'; +const DRAFTS_KEY = 'gev.mobile.threads.drafts.v1'; + +function readLocal(key, fallback) { + try { + return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback; + } catch { + return fallback; + } +} +function writeLocal(key, v) { + try { + localStorage.setItem(key, JSON.stringify(v)); + } catch { + /* ignore */ + } +} + +/* ------------------------------------------------------------------ */ +/* Thread models */ +/* ------------------------------------------------------------------ */ + +/** Example hypotheses: fictional authors, written to show the format. */ +const EXAMPLE_HYPOTHESES = [ + { + id: 'H1', + author: { kind: 'person', handle: '@rivergauge_example' }, + at: '2026-08-28T09:12:00Z', + text: 'The outburst began at the debris-dammed lake (O2) after the second landslide (O3) loaded the dam.', + confidence: 0.7, + cites: ['O2', 'O3'], + agree: 12, + dispute: 3, + }, + { + id: 'H2', + author: { kind: 'ai', handle: 'AI · example model' }, + at: '2026-08-28T11:40:00Z', + text: 'Treat the Timure cluster (O5) as one timing observation: it groups five map placements, not five independent timings.', + confidence: 0.55, + cites: ['O5'], + agree: 8, + dispute: 0, + prompt: 'Which observations are not independent for timing?', + }, + { + id: 'H3', + author: { kind: 'person', handle: '@northbank_example' }, + at: '2026-08-29T06:05:00Z', + text: 'The Rasuwagadhi witness clip (W1) was filmed from the north bank, upstream of the border gate (O4).', + confidence: 0.4, + cites: ['W1', 'O4'], + agree: 4, + dispute: 5, + }, +]; + +async function bhoteThread() { + const pack = await loadEventPack(); + const obs = []; + obs.push({ + id: 'B0', + title: 'Historical reference image', + phase: 'imagery', + lat: (pack.imagery.rectangle.north + pack.imagery.rectangle.south) / 2, + lon: (pack.imagery.rectangle.east + pack.imagery.rectangle.west) / 2, + source: pack.imagery.before.source, + via: 'Vantor Open Data Program', + time: pack.imagery.before.observedAt, + timeConf: 'acquisition date', + locConf: 'orthorectified scene', + url: '/events/bhote-koshi-2026/pre.webp', + record: pack.imagery.before, + }); + obs.push({ + id: 'A0', + title: 'Post-event image', + phase: 'imagery', + lat: obs[0].lat, + lon: obs[0].lon, + source: pack.imagery.after.source, + via: 'Vantor Open Data Program', + time: pack.imagery.after.observedAt, + timeConf: 'acquisition date', + locConf: 'orthorectified scene', + url: '/events/bhote-koshi-2026/post.webp', + record: pack.imagery.after, + }); + for (const w of pack.witnessAnchors) { + obs.push({ + id: 'W1', + title: 'Rasuwagadhi witness video', + phase: 'witness', + lat: w.lat, + lon: w.lon, + source: 'IndoWatchOSINT post (GeoConfirmed)', + via: w.provenance, + time: null, + timeConf: 'capture time unverified', + locConf: 'source geoconfirmed', + url: w.sourceUrl, + record: w, + }); + } + for (const e of pack.evidenceSpine) { + obs.push({ + id: `O${e.sequence}`, + seq: e.sequence, + title: e.title, + summary: e.summary, + phase: e.phase, + lat: e.lat, + lon: e.lon, + source: `${e.media?.platform || 'Source'} post`, + via: e.provenance?.via, + time: e.timing?.capturedAt, + timeConf: e.timing?.note || e.confidence?.time, + locConf: e.confidence?.location, + url: e.media?.sourceUrl, + poster: e.media?.posterUrl, + progress: e.trigger?.storyProgress, + rights: e.media?.rightsStatus, + record: e, + }); + } + obs.push({ + id: 'C1', + title: 'River centreline corridor', + phase: 'geometry', + lat: pack.reconstruction.corridor[0].lat, + lon: pack.reconstruction.corridor[0].lon, + source: 'GeoPera reconstruction (commit 59ee5bd)', + via: `${pack.reconstruction.corridor.length} points, every fourth published point inside the image bounds`, + time: null, + timeConf: 'not a modelled arrival time', + locConf: 'published centreline', + record: { corridor: pack.reconstruction.corridor }, + }); + for (const o of obs) o.hash = await sha256Hex(canonical(o.record)); + const steps = pack.evidenceSpine.map((e) => ({ + id: `O${e.sequence}`, + title: e.shortTitle || e.title, + phase: e.phase, + lat: e.lat, + lon: e.lon, + elev: e.fallbackElevationM, + progress: e.trigger?.storyProgress ?? 0, + })); + return { + id: pack.id, + real: true, + title: pack.title, + place: 'Rasuwa, Nepal ↔ Gyirong, Tibet', + observedDate: pack.observedDate, + created: `${pack.observedDate}T00:00:00Z`, + caveat: pack.reconstruction.caveat, + label: pack.reconstruction.label, + elapsedSeconds: pack.reconstruction.elapsedSeconds, + license: + 'CC BY-NC 4.0 · GeoPera · Vantor Open Data · GeoGeorgeShadrach map', + before: '/events/bhote-koshi-2026/pre.webp', + after: '/events/bhote-koshi-2026/post.webp', + viewpoint: pack.viewpoints.corridor, + corridor: pack.reconstruction.corridor, + observations: obs, + hypotheses: EXAMPLE_HYPOTHESES.map((x) => ({ ...x, example: true })), + steps, + }; +} + +async function draftThread(d) { + const o = { + id: 'O1', + title: d.title, + phase: 'situation snapshot', + lat: d.lat, + lon: d.lon, + source: d.source || 'Live feed', + via: 'Snapshot when the thread was started', + time: d.createdAt, + timeConf: 'snapshot time', + locConf: 'feed position', + record: d.snapshot || d, + }; + o.hash = await sha256Hex(canonical(o.record)); + return { + id: d.id, + real: false, + draft: true, + title: d.title, + place: d.place, + created: d.createdAt, + observations: [o], + hypotheses: [], + steps: [{ id: 'O1', title: d.title, lat: d.lat, lon: d.lon, progress: 0 }], + }; +} + +/* ------------------------------------------------------------------ */ +/* Ledger */ +/* ------------------------------------------------------------------ */ + +async function buildLedger(thread, local) { + const entries = []; + const push = async (type, actor, at, payload) => { + const prev = entries.length ? entries[entries.length - 1].hash : null; + const payloadHash = await sha256Hex(canonical(payload)); + const seq = entries.length; + const hash = await sha256Hex( + canonical({ seq, at, type, actor, payloadHash, prev }), + ); + entries.push({ seq, at, type, actor, payload, payloadHash, prev, hash }); + }; + await push( + 'thread.create', + thread.real ? 'event pack' : 'you (this device)', + thread.created, + { id: thread.id, title: thread.title, license: thread.license || null }, + ); + for (const o of thread.observations) + await push('observation.add', o.source, o.time || thread.created, { + id: o.id, + title: o.title, + recordHash: o.hash, + }); + for (const hy of [...thread.hypotheses, ...(local.proposals || [])]) { + await push( + hy.author.kind === 'ai' + ? hy.simulated + ? 'ai.proposal' + : 'hypothesis.propose' + : 'hypothesis.propose', + hy.author.handle, + hy.at, + { + id: hy.id, + text: hy.text, + cites: hy.cites, + confidence: hy.confidence, + example: hy.example || undefined, + ...(hy.simulated + ? { + prompt: hy.prompt, + model: hy.model, + inputs: hy.inputs, + simulated: true, + } + : {}), + }, + ); + } + for (const v of local.votes || []) + await push(`vote.${v.kind}`, 'you (this device)', v.at, { + hypothesis: v.id, + }); + return entries; +} + +async function verifyLedger(entries) { + let prev = null; + for (const e of entries) { + const ph = await sha256Hex(canonical(e.payload)); + const hh = await sha256Hex( + canonical({ + seq: e.seq, + at: e.at, + type: e.type, + actor: e.actor, + payloadHash: ph, + prev, + }), + ); + if (ph !== e.payloadHash || hh !== e.hash || e.prev !== prev) + return { ok: false, at: e.seq }; + prev = e.hash; + } + return { ok: true, n: entries.length, head: prev }; +} + +/* ------------------------------------------------------------------ */ +/* Simulated AI refinement (no network) */ +/* ------------------------------------------------------------------ */ + +function simulateRefinement(thread, prompt) { + const words = prompt.toLowerCase().match(/[a-z]{4,}/g) || []; + const scored = thread.observations + .map((o) => { + const hay = `${o.title} ${o.summary || ''} ${o.phase}`.toLowerCase(); + return { o, s: words.reduce((a, w) => a + (hay.includes(w) ? 1 : 0), 0) }; + }) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, 3) + .map((x) => x.o); + const cites = scored.length ? scored : thread.observations.slice(0, 2); + const anchors = cites + .map((o) => `${o.id} ${o.title} (${o.lat.toFixed(4)}, ${o.lon.toFixed(4)})`) + .join('; '); + return { + text: `Proposed refinement for “${prompt.trim()}”: candidate anchors ${anchors}. Needs a person to check the anchors against the imagery before it can be cited.`, + cites: cites.map((o) => o.id), + inputs: cites.map((o) => ({ id: o.id, recordHash: o.hash })), + }; +} + +/* ------------------------------------------------------------------ */ +/* View */ +/* ------------------------------------------------------------------ */ + +export function createThreads(view, { bindScroll, onPlay, onShowOnGlobe }) { + view.innerHTML = ` +
+ +

Threads

+
Reconstructions built in the open: observations, hypotheses, a ledger
+
+
+ `; + const list = $('.th-list', view); + const detail = $('.th-detail', view); + const cards = $('.th-cards', view); + let bhote = null; + let open = null; // { thread, ledger } + + view.addEventListener( + 'scroll', + () => view.classList.toggle('is-scrolled', view.scrollTop > 44), + { passive: true }, + ); + bindScroll(view); + + async function renderList() { + bhote = + bhote || (await bhoteThread().catch((err) => (console.error(err), null))); + const drafts = readLocal(DRAFTS_KEY, []); + const local = readLocal(LOCAL_KEY, {})[bhote?.id] || {}; + cards.innerHTML = ` + ${ + bhote + ? `` + : '

The bundled event pack could not be loaded.

' + } + ${drafts + .map( + ( + d, + ) => ``, + ) + .join('')} +
More reconstructions Example
+ ${[ + { + t: 'Stadium encore, rebuilt from crowd captures', + p: '42 signed captures · 3 hypotheses', + f: 'eyes', + i: 'eyes', + }, + { + t: '7700 squawk replay over the Channel', + p: 'ADS-B track · 2 witness clips', + f: 'sky', + i: 'sky', + }, + { + t: 'Ferry crossing in fog, harbour pilots', + p: 'AIS tracks · radar · 1 hypothesis', + f: 'sea', + i: 'sea', + }, + ] + .map( + (x) => + ``, + ) + .join('')} +

Example threads are placeholders showing the genres the format supports; they contain no real records.

`; + } + + cards.addEventListener('click', async (e) => { + const t = e.target.closest('[data-thread]'); + if (t && bhote) return openThread(bhote); + const d = e.target.closest('[data-draft]'); + if (d) { + const rec = readLocal(DRAFTS_KEY, []).find( + (x) => x.id === d.dataset.draft, + ); + if (rec) openThread(await draftThread(rec)); + return; + } + if (e.target.closest('[data-example]')) + toast('Example thread — concept only, no records', { + icon: icon('threads'), + }); + }); + + /* ---------- detail ---------- */ + function hypHtml(hy, votes) { + const v = votes.filter((x) => x.id === hy.id); + const agree = (hy.agree || 0) + v.filter((x) => x.kind === 'agree').length; + const dispute = + (hy.dispute || 0) + v.filter((x) => x.kind === 'dispute').length; + const mine = v.at(-1)?.kind; + const total = agree + dispute || 1; + const ai = hy.author.kind === 'ai'; + return `
+
${esc(hy.id)}${ai ? icon('sparkles') : ''}${esc(hy.author.handle)} + ${hy.example ? 'Example' : ''}${hy.simulated ? 'Proposed · local simulation' : ''}${esc(ago(hy.at))}
+

${esc(hy.text)}

+
${hy.cites.map((c) => ``).join('')}
+ ${hy.simulated ? `
prompt
${esc(hy.prompt)}
model
${esc(hy.model)}
inputs
${hy.inputs.map((i) => `${esc(i.id)}:${esc(i.recordHash.slice(0, 10))}`).join(' ')}
` : ''} +
+
Confidence${hy.confidence == null ? 'unscored' : `${Math.round(hy.confidence * 100)}%`}
+
+ +
+
`; + } + + function obsHtml(o) { + return `
  • + +
  • `; + } + + async function renderDetail() { + const { thread } = open; + const local = readLocal(LOCAL_KEY, {})[thread.id] || {}; + const votes = local.votes || []; + const proposals = local.proposals || []; + open.ledger = await buildLedger(thread, local); + const L = open.ledger; + detail.innerHTML = ` + +
    + ${thread.before ? `
    2021 reference imagery
    2021 · reference
    2026 post-event imagery
    27 Aug 2026 · post-event
    ` : ''} +

    ${esc(thread.title)}

    +
    ${esc(thread.place || '')}${thread.real ? ' · real event pack' : ' · your draft'}
    + ${thread.label ? `
    ${esc(thread.label)}${esc(thread.caveat)}
    ` : ''} +
    + + +
    1

    Observations

    Immutable. Each has a source, a time and a hash. Nobody edits an observation.

    +
      ${thread.observations.map(obsHtml).join('')}
    + +
    2

    Hypotheses

    Claims that connect observations. Cite, dispute or supersede — never edit.

    + ${thread.hypotheses.some((x) => x.example) ? '
    Example content These hypotheses, authors and vote counts are fictional, written to show the format. The observations above are real.
    ' : ''} +
    ${[...thread.hypotheses, ...proposals].map((x) => hypHtml(x, votes)).join('') || '

    No hypotheses yet.

    '}
    +
    + + +
    Creates a proposed hypothesis with its prompt, model and inputs in the ledger. Local simulation — no network model is called.
    +
    + +
    3

    Narrative

    A camera path through space and time that cites the strata below it.

    +
    + +
    ${thread.steps.length} stops${thread.elapsedSeconds ? ` · ${Math.round(thread.elapsedSeconds / 60)} min schematic` : ''}Flies the globe down the valley, scrubbing the time ribbon.
    +
    +
      ${thread.steps.map((s, i) => `
    1. ${String(i + 1).padStart(2, '0')}${esc(s.title)}${esc(s.phase || '')}
    2. `).join('')}
    + +
    #

    Ledger

    Every change, hash-chained with SHA-256 on this device. Anyone can export and verify it.

    +
    +
      ${L.map( + ( + e, + ) => `
    1. +
    2. `, + ).join('')}
    +

    Head ${esc(L.at(-1)?.hash.slice(0, 24) || '')}… · ${L.length} entries

    +
    `; + placeSeg(); + } + + function placeSeg(id) { + const seg = $('.strata-nav .segmented', detail); + if (!seg) return; + const btn = id + ? $(`[data-jump="${id}"]`, seg) + : $('[aria-pressed="true"]', seg); + $$('[data-jump]', seg).forEach((b) => + b.setAttribute('aria-pressed', String(b === btn)), + ); + const th = $('.seg-thumb', seg); + th.style.width = `${btn.offsetWidth}px`; + th.style.transform = `translateX(${btn.offsetLeft}px)`; + } + + async function openThread(thread) { + open = { thread }; + await renderDetail(); + detail.hidden = false; + view.classList.add('in-detail'); + view.scrollTop = 0; + if (!reducedMotion()) { + detail.animate( + [{ transform: 'translateX(100%)' }, { transform: 'none' }], + { duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' }, + ); + list.animate( + [ + { transform: 'none', opacity: 1 }, + { transform: 'translateX(-28%)', opacity: 0.4 }, + ], + { duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' }, + ); + } + list.hidden = true; + requestAnimationFrame(() => placeSeg()); + } + + function closeThread() { + list.hidden = false; + view.classList.remove('in-detail'); + if (!reducedMotion()) { + list.animate( + [ + { transform: 'translateX(-28%)', opacity: 0.4 }, + { transform: 'none', opacity: 1 }, + ], + { duration: 420, easing: 'cubic-bezier(.32,1.1,.4,1)' }, + ); + detail + .animate([{ transform: 'none' }, { transform: 'translateX(100%)' }], { + duration: 380, + easing: 'cubic-bezier(.4,0,.6,1)', + }) + .finished.then(() => (detail.hidden = true)); + } else detail.hidden = true; + open = null; + renderList(); + } + + function saveLocal(fn) { + const all = readLocal(LOCAL_KEY, {}); + const cur = all[open.thread.id] || { votes: [], proposals: [] }; + fn(cur); + all[open.thread.id] = cur; + writeLocal(LOCAL_KEY, all); + } + + detail.addEventListener('click', async (e) => { + if (e.target.closest('.back-btn')) return closeThread(); + const jump = e.target.closest('[data-jump]'); + if (jump) { + placeSeg(jump.dataset.jump); + const sec = $(`#st-${jump.dataset.jump}`, detail); + view.scrollTo({ + top: sec.offsetTop - 110, + behavior: reducedMotion() ? 'auto' : 'smooth', + }); + return; + } + const row = e.target.closest('.obs-row'); + if (row) { + const more = row.nextElementSibling; + more.hidden = !more.hidden; + row.setAttribute('aria-expanded', String(!more.hidden)); + return; + } + const g = e.target.closest('[data-globe]'); + if (g) { + const o = open.thread.observations.find((x) => x.id === g.dataset.globe); + if (o) onShowOnGlobe?.(open.thread, o); + return; + } + const cite = e.target.closest('[data-cite]'); + if (cite) { + const li = $(`.obs[data-o="${cite.dataset.cite}"]`, detail); + if (li) { + const more = $('.obs-more', li); + more.hidden = false; + li.scrollIntoView({ + block: 'center', + behavior: reducedMotion() ? 'auto' : 'smooth', + }); + li.classList.remove('flash'); + void li.offsetWidth; + li.classList.add('flash'); + } + return; + } + const vote = e.target.closest('[data-vote]'); + if (vote) { + const id = vote.closest('[data-h]').dataset.h; + haptic('tick'); + saveLocal((cur) => + cur.votes.push({ + id, + kind: vote.dataset.vote, + at: new Date().toISOString(), + }), + ); + await renderDetail(); + placeSeg('hyp'); + toast( + `${vote.dataset.vote === 'agree' ? 'Agreed with' : 'Disputed'} ${id} · ledger +1`, + { icon: icon('threads') }, + ); + return; + } + if (e.target.closest('.narr-play')) { + onPlay?.(open.thread); + return; + } + const led = e.target.closest('[data-led]')?.dataset.led; + if (led === 'verify') { + const t0 = performance.now(); + const r = await verifyLedger(open.ledger); + const st = $('.led-status', detail); + st.textContent = r.ok + ? `✓ ${r.n} entries linked · ${Math.round(performance.now() - t0)} ms` + : `✕ broken at #${r.at}`; + st.className = `led-status num ${r.ok ? 'ok' : 'bad'}`; + haptic(r.ok ? 'success' : 'warn'); + } else if (led === 'export') { + const blob = new Blob( + [ + JSON.stringify( + { + thread: open.thread.id, + title: open.thread.title, + exportedAt: new Date().toISOString(), + algorithm: + 'sha256(canonical({seq,at,type,actor,payloadHash,prev}))', + entries: open.ledger, + }, + null, + 2, + ), + ], + { type: 'application/json' }, + ); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${open.thread.id}.ledger.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + toast('Ledger exported', { icon: icon('download') }); + } + const lr = e.target.closest('.led-row'); + if (lr) { + const pre = lr.nextElementSibling; + pre.hidden = !pre.hidden; + lr.setAttribute('aria-expanded', String(!pre.hidden)); + } + }); + + detail.addEventListener('submit', async (e) => { + e.preventDefault(); + const ta = $('#ai-prompt', detail); + const prompt = ta.value.trim() || ta.placeholder; + const sim = simulateRefinement(open.thread, prompt); + const n = + (readLocal(LOCAL_KEY, {})[open.thread.id]?.proposals?.length || 0) + + open.thread.hypotheses.length + + 1; + saveLocal((cur) => + cur.proposals.push({ + id: `H${n}`, + author: { kind: 'ai', handle: 'AI · local simulation' }, + at: new Date().toISOString(), + text: sim.text, + confidence: null, + cites: sim.cites, + agree: 0, + dispute: 0, + simulated: true, + prompt, + model: 'local-simulation-v0 (no network)', + inputs: sim.inputs, + }), + ); + haptic('success'); + await renderDetail(); + placeSeg('hyp'); + $(`[data-h="H${n}"]`, detail)?.scrollIntoView({ + block: 'center', + behavior: reducedMotion() ? 'auto' : 'smooth', + }); + toast(`Proposed H${n} · recorded in the ledger`, { + icon: icon('sparkles'), + }); + }); + + new ResizeObserver(() => placeSeg()).observe(view); + renderList(); + + return { + refresh: renderList, + async openById(id) { + if (!bhote) bhote = await bhoteThread(); + if (id === bhote.id) return openThread(bhote); + const rec = readLocal(DRAFTS_KEY, []).find((x) => x.id === id); + if (rec) return openThread(await draftThread(rec)); + }, + /** Start a local draft thread from a situation or contact. */ + startDraft(s) { + const drafts = readLocal(DRAFTS_KEY, []); + const id = `draft-${String(s.id).replace(/[^a-z0-9-]/gi, '')}`; + if (!drafts.some((d) => d.id === id)) + drafts.unshift({ + id, + title: s.title, + place: s.place, + lat: s.lat, + lon: s.lon, + family: s.family, + source: s.source, + createdAt: new Date().toISOString(), + snapshot: { + id: s.id, + title: s.title, + metrics: s.metrics || null, + source: s.source || null, + updatedAt: s.updatedAt || null, + lon: s.lon, + lat: s.lat, + }, + }); + writeLocal(DRAFTS_KEY, drafts.slice(0, 20)); + return id; + }, + thread: () => open?.thread || null, + bhote: async () => (bhote = bhote || (await bhoteThread())), + }; +} diff --git a/mobile/ui/timeRibbon.js b/mobile/ui/timeRibbon.js new file mode 100644 index 000000000..91d79bf55 --- /dev/null +++ b/mobile/ui/timeRibbon.js @@ -0,0 +1,412 @@ +/** + * Time ribbon: −72 h ← NOW → +120 h with momentum drag, haptic hour detents, + * event pips and a LIVE pill that snaps back. Fast drags move in hours, + * slow drags in minutes. Calls `onTime(Date | null)` (null = live). + */ + +import { h, clamp, haptic, reducedMotion } from './dom.js'; + +const HOUR = 3600e3; + +export function createTimeRibbon(parent, { getWindow, onTime }) { + const el = + h(`
    + +
    + + +
    +
    Now
    +
    `); + parent.appendChild(el); + const track = el.querySelector('.rb-track'); + const canvas = el.querySelector('canvas'); + const ctx = canvas.getContext('2d'); + const liveBtn = el.querySelector('.rb-live'); + const readB = el.querySelector('.rb-read b'); + const readS = el.querySelector('.rb-read span'); + + let win = getWindow(); + let custom = null; // { start, end, label, pips, format } + let pips = []; + let t = Date.now(); // cursor time (ms) + let live = true; + let pxPerHour = 7; + let W = 0; + let Hh = 0; + let dpr = 1; + let raf = 0; + let vel = 0; // ms per frame + let lastHour = Math.floor(t / HOUR); + let lastTick = 0; + let emitTimer = 0; + + const span = () => (custom ? custom.end - custom.start : win.end - win.start); + const start = () => (custom ? custom.start : win.start.getTime()); + const end = () => (custom ? custom.end : win.end.getTime()); + const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now()); + + function resize() { + const r = track.getBoundingClientRect(); + dpr = Math.min(2, window.devicePixelRatio || 1); + W = r.width; + Hh = r.height; + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(Hh * dpr); + // Custom windows (minutes long) scale to fit ~1.6 track widths. + pxPerHour = custom ? (W * 1.6) / (span() / HOUR) : 7; + draw(); + } + + function x(ms) { + return W / 2 + ((ms - t) / HOUR) * pxPerHour; + } + + function draw() { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, Hh); + const s = start(); + const e = end(); + const n = nowMs(); + const mid = Hh / 2 + 4; + // Window body + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + const xs = Math.max(0, x(s)); + const xe = Math.min(W, x(e)); + ctx.fillRect(xs, mid - 9, xe - xs, 18); + // Forecast region + if (!custom) { + const xn = x(n); + if (xn < W) { + const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0); + g.addColorStop(0, 'rgba(138,180,248,0.16)'); + g.addColorStop(1, 'rgba(138,180,248,0.03)'); + ctx.fillStyle = g; + ctx.fillRect( + Math.max(0, xn), + mid - 9, + Math.min(W, xe) - Math.max(0, xn), + 18, + ); + } + } + // Ticks + const stepMin = custom ? (span() / HOUR < 3 ? 5 : 15) : 60; + const step = stepMin * 60e3; + const first = Math.ceil((t - (W / 2 / pxPerHour) * HOUR) / step) * step; + const last = t + (W / 2 / pxPerHour) * HOUR; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + for ( + let m = Math.max(first, s - (s % step)); + m <= Math.min(last, e); + m += step + ) { + const px = x(m); + const d = new Date(m); + const major = custom + ? (m - s) % (stepMin * 4 * 60e3) === 0 + : d.getHours() === 0; + const mid6 = !custom && d.getHours() % 6 === 0; + const future = !custom && m > n; + const len = major ? 14 : mid6 ? 9 : 5; + ctx.strokeStyle = future + ? 'rgba(170,200,255,0.42)' + : major + ? 'rgba(255,255,255,0.75)' + : 'rgba(255,255,255,0.32)'; + ctx.lineWidth = major ? 1.3 : 1; + if (future) ctx.setLineDash([1.5, 2]); + ctx.beginPath(); + ctx.moveTo(px, mid - len / 2); + ctx.lineTo(px, mid + len / 2); + ctx.stroke(); + ctx.setLineDash([]); + if (major || (mid6 && pxPerHour > 4)) { + ctx.font = `${major ? 650 : 500} 9.5px ui-rounded, system-ui, sans-serif`; + ctx.fillStyle = major + ? 'rgba(255,255,255,0.8)' + : 'rgba(255,255,255,0.38)'; + const label = custom + ? custom.format(m) + : major + ? d + .toLocaleDateString(undefined, { weekday: 'short' }) + .toUpperCase() + : `${String(d.getHours()).padStart(2, '0')}`; + ctx.fillText(label, px, mid - 12); + } + } + // NOW marker + if (!custom) { + const xn = x(n); + if (xn > -10 && xn < W + 10) { + ctx.fillStyle = '#FF453A'; + ctx.beginPath(); + ctx.arc(xn, mid + 13, 2.4, 0, Math.PI * 2); + ctx.fill(); + } + } + // Event pips + for (const p of pips) { + const px = x(p.at); + if (px < -6 || px > W + 6) continue; + ctx.fillStyle = p.color || '#FFD166'; + ctx.beginPath(); + ctx.arc(px, mid, 3.4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(5,7,11,0.9)'; + ctx.lineWidth = 1.2; + ctx.stroke(); + } + // Edge fades + const fade = (x0, x1) => { + const g = ctx.createLinearGradient(x0, 0, x1, 0); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = g; + ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh); + ctx.restore(); + }; + fade(0, 26); + fade(W, W - 26); + updateReadout(); + } + + function fmtRel(ms) { + const d = ms / 60e3; + const sign = d < 0 ? '−' : '+'; + const a = Math.abs(d); + if (a < 60) return `${sign}${Math.round(a)} min`; + const hrs = Math.floor(a / 60); + const mins = Math.round(a % 60); + return `${sign}${hrs} h${mins ? ` ${mins}` : ''}`; + } + + function updateReadout() { + if (custom) { + readB.textContent = custom.format(t); + readS.textContent = custom.label || ''; + track.setAttribute( + 'aria-valuetext', + `${custom.format(t)} ${custom.label || ''}`, + ); + return; + } + if (live) { + readB.textContent = 'Now'; + readS.textContent = new Date().toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + } else { + readB.textContent = fmtRel(t - Date.now()); + readS.textContent = new Date(t).toLocaleString(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); + } + track.setAttribute( + 'aria-valuetext', + live ? 'Live' : `${readB.textContent}, ${readS.textContent}`, + ); + } + + function setLive(v) { + live = v; + el.classList.toggle('is-live', v); + liveBtn.classList.toggle('is-live', v); + liveBtn.querySelector('span:last-child').textContent = v + ? 'LIVE' + : custom + ? 'RESET' + : 'LIVE'; + liveBtn.setAttribute('aria-pressed', String(v)); + } + + function emit(immediate = false) { + const fire = () => { + emitTimer = 0; + onTime(live && !custom ? null : new Date(t)); + }; + if (immediate) { + clearTimeout(emitTimer); + fire(); + } else if (!emitTimer) emitTimer = setTimeout(fire, 90); + } + + function setCursor(ms, { fromUser = false, quant = 0 } = {}) { + const s = start(); + const e = end(); + let v = ms; + if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.2 + 1)) * HOUR * 2; + if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.2 + 1)) * HOUR * 2; + if (quant) v = Math.round(v / quant) * quant; + t = v; + const hr = Math.floor(t / HOUR); + if (fromUser && hr !== lastHour) { + const now = performance.now(); + if (now - lastTick > 55) { + haptic('tick'); + lastTick = now; + } + lastHour = hr; + } + if (fromUser) { + const near = !custom && Math.abs(t - Date.now()) < 4 * 60e3; + setLive(near); + emit(); + } + draw(); + } + + /* ---------- drag with momentum ---------- */ + let drag = null; + track.addEventListener('pointerdown', (e) => { + track.setPointerCapture(e.pointerId); + cancelAnimationFrame(raf); + drag = { + x: e.clientX, + t0: t, + samples: [{ x: e.clientX, ts: performance.now() }], + }; + el.classList.add('scrubbing'); + }); + track.addEventListener('pointermove', (e) => { + if (!drag) return; + const dx = e.clientX - drag.x; + drag.samples.push({ x: e.clientX, ts: performance.now() }); + if (drag.samples.length > 6) drag.samples.shift(); + const a = drag.samples[0]; + const b = drag.samples[drag.samples.length - 1]; + const speed = b.ts > a.ts ? Math.abs((b.x - a.x) / (b.ts - a.ts)) : 0; // px/ms + const quant = custom ? 60e3 : speed > 0.6 ? HOUR : 5 * 60e3; + setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true, quant }); + }); + const release = () => { + if (!drag) return; + el.classList.remove('scrubbing'); + const a = drag.samples[0]; + const b = drag.samples[drag.samples.length - 1]; + const pxPerMs = b.ts > a.ts ? (b.x - a.x) / (b.ts - a.ts) : 0; + drag = null; + vel = (-(pxPerMs * 16.7) / pxPerHour) * HOUR; + const fast = Math.abs(pxPerMs) > 0.6; + if (reducedMotion() || Math.abs(pxPerMs) < 0.05) return settle(fast); + const stepFn = () => { + vel *= 0.93; + setCursor(t + vel, { fromUser: true }); + if (Math.abs(vel) > 60e3 && t > start() && t < end()) + raf = requestAnimationFrame(stepFn); + else settle(fast); + }; + raf = requestAnimationFrame(stepFn); + }; + track.addEventListener('pointerup', release); + track.addEventListener('pointercancel', release); + + function settle(fast) { + const s = start(); + const e = end(); + const target = clamp(t, s, e); + const q = custom ? 60e3 : fast ? HOUR : 5 * 60e3; + animateTo(Math.round(target / q) * q, () => emit(true)); + } + + function animateTo(target, done) { + cancelAnimationFrame(raf); + const from = t; + const t0 = performance.now(); + const dur = reducedMotion() ? 1 : 420; + const step = () => { + const k = clamp((performance.now() - t0) / dur, 0, 1); + const e = 1 - (1 - k) ** 3; + t = from + (target - from) * e; + draw(); + if (k < 1) raf = requestAnimationFrame(step); + else done?.(); + }; + raf = requestAnimationFrame(step); + } + + function goLive() { + haptic('tick'); + if (custom) { + animateTo(custom.start, () => emit(true)); + return; + } + setLive(true); + animateTo(Date.now(), () => emit(true)); + } + liveBtn.addEventListener('click', goLive); + + track.addEventListener('keydown', (e) => { + const stepMs = custom ? 60e3 : e.shiftKey ? 6 * HOUR : HOUR; + if (e.key === 'ArrowRight') setCursor(t + stepMs, { fromUser: true }); + else if (e.key === 'ArrowLeft') setCursor(t - stepMs, { fromUser: true }); + else if (e.key === 'Home' || e.key === 'End') goLive(); + else return; + e.preventDefault(); + }); + + // Live: keep the cursor glued to now while the planet turns. + setInterval(() => { + if (live && !custom && !drag) { + t = Date.now(); + win = getWindow(); + draw(); + } + }, 15000); + + new ResizeObserver(resize).observe(track); + resize(); + setLive(true); + + return { + el, + setPips(list) { + pips = list.filter((p) => Number.isFinite(p.at)); + draw(); + }, + /** Use a custom window (e.g. a reconstruction's schematic clock). */ + setWindow(w) { + custom = w + ? { + ...w, + start: +w.start, + end: +w.end, + format: w.format || ((ms) => new Date(ms).toLocaleTimeString()), + } + : null; + el.classList.toggle('custom', !!custom); + if (custom) { + t = custom.start; + setLive(false); + } else { + win = getWindow(); + t = Date.now(); + setLive(true); + } + resize(); + }, + /** Programmatic scrub (narrative playback). */ + seek(ms) { + cancelAnimationFrame(raf); + t = ms; + draw(); + }, + goLive, + get time() { + return live && !custom ? null : new Date(t); + }, + get isLive() { + return live && !custom; + }, + get debug() { + return { t, pxPerHour, W, Hh, live, custom: !!custom }; + }, + }; +} diff --git a/mobile/ui/world.js b/mobile/ui/world.js new file mode 100644 index 000000000..cc0d7f640 --- /dev/null +++ b/mobile/ui/world.js @@ -0,0 +1,553 @@ +/** + * Card → world: the hero transition into the live globe, the situation + * brief sheet (peek / medium / large) and the fold back into the card. + */ + +import { icon, FAMILY_ICON, FAMILIES } from './icons.js'; +import { + $, + h, + esc, + ago, + fmtNum, + fmtCoord, + haptic, + toast, + store, + reducedMotion, + SPRING, + SPRING_SOFT, + EASE_OUT, + sparkPath, + esriThumb, + emitter, + safeInsets, +} from './dom.js'; +import { createSheet } from './sheet.js'; +import { isReconstruction } from './feed.js'; + +const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name])); +let eventPack = null; +export async function loadEventPack() { + if (!eventPack) + eventPack = fetch('/events/bhote-koshi-2026/event.json') + .then((r) => { + if (!r.ok) throw new Error(`event pack ${r.status}`); + return r.json(); + }) + .catch((err) => { + eventPack = null; // let the next caller retry + throw err; + }); + return eventPack; +} + +export function createWorld({ + app, + stage, + globe, + getCard, + isTwoPane, + onThread, + onFollowChange, +}) { + const ev = emitter(); + let current = null; + let busy = false; + let originCard = null; + + /* ---------- chrome: back button ---------- */ + const back = h( + ``, + ); + $('#globe-chrome').appendChild(back); + back.addEventListener('click', () => close()); + + /* ---------- the brief sheet ---------- */ + const sheet = createSheet({ + host: stage, + label: 'Situation brief', + detents: ['peek', 'medium', 'large'], + dismissible: true, + closeButton: true, + className: 'brief-sheet', + heights: { + peek: () => 22 + (sheet?.head?.offsetHeight || 92) + 16 + safeBottom(), + medium: (H) => Math.round(H * 0.47), + large: (H) => Math.round(H * 0.92), + }, + }); + sheet.on('close', () => { + if (current && !closingFromCode) close({ fromSheet: true }); + }); + sheet.on('detent', (d) => { + app.dataset.sheet = d; + stage.style.setProperty('--sheet-h', `${sheet.visibleHeight()}px`); + }); + let closingFromCode = false; + + const safeBottom = () => safeInsets().bottom; + + // The world wins: start panning the globe and the sheet steps aside. + $('#globe').addEventListener( + 'pointerdown', + () => { + if (current && sheet.isOpen && sheet.detent !== 'peek') + sheet.setDetent('peek'); + }, + { capture: true, passive: true }, + ); + + // Edge-swipe back (leading edge, like a navigation pop). + let edge = null; + stage.addEventListener( + 'touchstart', + (e) => { + if (!current) return; + const t = e.touches[0]; + if (t.clientX - stage.getBoundingClientRect().left < 22) + edge = { x: t.clientX, y: t.clientY }; + }, + { passive: true }, + ); + stage.addEventListener( + 'touchmove', + (e) => { + if (!edge) return; + const t = e.touches[0]; + if (t.clientX - edge.x > 70 && Math.abs(t.clientY - edge.y) < 60) { + edge = null; + close(); + } + }, + { passive: true }, + ); + stage.addEventListener('touchend', () => (edge = null), { passive: true }); + + /* ---------- brief rendering ---------- */ + function metricHtml(m) { + const v = + typeof m.value === 'number' + ? fmtNum(m.value, m.value % 1 ? 1 : 0) + : m.value; + return `
    ${esc(v)}${m.unit ? `${esc(m.unit)}` : ''}
    ${esc(m.label)}
    `; + } + + function sparkHtml(s) { + if (!Array.isArray(s.series) || s.series.length < 3) return ''; + const W = 220; + const H = 42; + const d = sparkPath(s.series, W, H, 3); + const last = d.split(/[ML]/).filter(Boolean).pop().split(' ').map(Number); + const m = s.metrics?.[0]; + return `
    Trend${esc(m?.label || '')} · last ${s.series.length} samples
    +
    +
    ${esc(m ? fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0) : '')}${esc(m?.unit || '')} now
    `; + } + + function contextEvidence(s) { + const span = Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6); + const frames = [ + { t: 'Region', span: span * 6 }, + { t: 'Area', span }, + { t: 'Close', span: span / 5 }, + ]; + return frames + .map( + ( + f, + i, + ) => ` + + ${i + 1}
    ${f.t} context
    Esri World Imagery · archival
    `, + ) + .join(''); + } + + async function packEvidence() { + const pack = await loadEventPack(); + const pre = `A
    Before · 16 Oct 2021
    Vantor WorldView-2
    + B
    After · 27 Aug 2026
    Vantor WorldView-3
    `; + return ( + pre + + pack.evidenceSpine + .map( + ( + e, + ) => ` + + ${e.sequence}
    ${esc(e.shortTitle || e.title)}
    ${esc(e.media?.platform || 'Source')} · ${esc(e.confidence?.time || '')}
    `, + ) + .join('') + ); + } + + function render(s) { + const followed = store.isFollowed(s.id); + const recon = isReconstruction(s); + sheet.sheet.dataset.family = s.family; + const m0 = s.metrics?.[0]; + const status = s.live + ? `Live` + : recon + ? 'Reconstruction' + : 'Stale'; + sheet.head.innerHTML = ` +
    ${icon(FAMILY_ICON[s.family] || 'now')}${esc(FAM_NAME[s.family] || s.family)}${status}·${esc(ago(s.updatedAt))}
    +

    ${esc(s.title)}

    ${m0 ? `${esc(typeof m0.value === 'number' ? fmtNum(m0.value, m0.value % 1 ? 1 : 0) : m0.value)}${esc(m0.unit || '')}` : ''}
    +
    ${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}
    `; + const metrics = (s.metrics || []).slice(0, 3); + const time = s.time + ? `${new Date(s.time.start).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} → ${new Date(s.time.end).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}` + : 'Live'; + const layers = globe.layers(); + const layerChips = (s.layers || []) + .map((id) => { + const l = layers.find((x) => x.id === id); + const fam = l?.family || id.split('.')[0]; + return `${icon(FAMILY_ICON[fam] || 'layers')}${esc(l?.name || id)}`; + }) + .join(''); + sheet.body.innerHTML = ` + ${metrics.length ? `
    ${metrics.map(metricHtml).join('')}
    ` : ''} +
    + + + + + +
    + ${sparkHtml(s)} +
    Evidence${recon ? 'sourced observations · tap for original' : 'context imagery'}
    +
    ${recon ? '
    '.repeat(4) : contextEvidence(s)}
    +
    Every number, its source and age
    + ${(s.metrics || []) + .map( + (m) => + ``, + ) + .join('')}
    ${esc(m.label)}${esc(typeof m.value === 'number' ? fmtNum(m.value, m.value % 1 ? 1 : 0) : m.value)} ${esc(m.unit || '')}${esc(m.source || s.source || '')} · ${esc(ago(m.updatedAt || s.updatedAt))}
    +
    Lenses this situation turns on
    ${layerChips || 'None'}
    +
    Details
    +
    Where
    ${esc(fmtCoord(s.lat, s.lon, 4))}
    +
    Window
    ${esc(time)}
    +
    Kind
    ${esc(s.kind || '—')}
    +
    Source
    ${esc(s.source || '—')}
    + ${s.caveat ? `
    Caveat${esc(s.caveat)}
    ` : ''}`; + if ( + recon && + (s.thread === 'bhote-koshi-2026' || s.id === 'bhote-koshi-2026') + ) { + packEvidence() + .then((html) => { + const strip = $('.evidence', sheet.body); + if (strip && current === s) strip.innerHTML = html; + return loadEventPack(); + }) + .then((pack) => { + if (current !== s || $('.caveat', sheet.body)) return; + sheet.body.insertAdjacentHTML( + 'beforeend', + `
    ${esc(pack.reconstruction.label)}${esc(pack.reconstruction.caveat)}
    `, + ); + }) + .catch((err) => console.warn(err)); + } + } + + sheet.root.addEventListener('click', async (e) => { + const act = e.target.closest('[data-act]')?.dataset.act; + if (!act || !current) return; + const s = current; + haptic('light'); + if (act === 'follow') { + const on = store.toggleFollow(s.id); + const b = e.target.closest('[data-act]'); + b.setAttribute('aria-pressed', String(on)); + b.querySelector('span').textContent = on ? 'Following' : 'Follow'; + toast(on ? `Following ${s.title}` : 'Unfollowed', { + icon: icon(on ? 'starFill' : 'star'), + }); + onFollowChange?.(s, on); + } else if (act === 'ride') { + sheet.setDetent('peek'); + const c = s.camera || {}; + await globe.flyTo({ + lon: c.lon ?? s.lon, + lat: c.lat ?? s.lat, + height: Math.max(1500, (c.height || 200e3) * 0.12), + heading: (c.heading || 0) + 25, + pitch: -18, + duration: 2.4, + }); + } else if (act === 'eye') { + const cams = globe + .contactsNear(s.lon, s.lat, 80) + .filter((c) => /cam|cctv|eye/i.test(c.kind)); + if (cams.length) { + globe.select(cams[0].id); + globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 3000 }); + toast(`Nearest eye: ${cams[0].title}`); + } else toast('No public camera within 80 km', { icon: icon('video') }); + } else if (act === 'thread') { + onThread?.(s); + } else if (act === 'share') { + const url = `${location.origin}${location.pathname}#s=${encodeURIComponent(s.id)}`; + try { + if (navigator.share) + await navigator.share({ + title: s.title, + text: `${s.title} — ${s.place}`, + url, + }); + else { + await navigator.clipboard.writeText(url); + toast('Link copied', { icon: icon('share') }); + } + } catch { + /* share cancelled */ + } + } + }); + + /* ---------- hero geometry ---------- */ + function targetRect() { + return stage.getBoundingClientRect(); + } + + function heroFor(src, rect) { + const el = h( + ``, + ); + Object.assign(el.style, { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + const img = el.querySelector('img'); + img.src = src || ''; + document.body.appendChild(el); + return { el, img }; + } + + async function imgSize(img) { + if (!img.complete || !img.naturalWidth) { + try { + await img.decode(); + } catch { + /* ignore */ + } + } + return { w: img.naturalWidth || 480, h: img.naturalHeight || 600 }; + } + + /** Place the hero image to cover `T`, and compute the transform that makes it cover card rect `C`. */ + function coverMath(nat, T, C) { + const sf = Math.max(T.width / nat.w, T.height / nat.h); + const sc = Math.max(C.width / nat.w, C.height / nat.h); + const iw = nat.w * sf; + const ih = nat.h * sf; + const left = (T.width - iw) / 2; + const top = (T.height - ih) / 2; + const dx = C.left + C.width / 2 - (T.left + T.width / 2); + const dy = C.top + C.height / 2 - (T.top + T.height / 2); + return { iw, ih, left, top, k: sc / sf, dx, dy }; + } + + const clipFor = (T, C, r) => + `inset(${C.top - T.top}px ${T.right - C.right}px ${T.bottom - C.bottom}px ${C.left - T.left}px round ${r}px)`; + + /* ---------- open ---------- */ + async function open(s, card = null) { + if (busy) return; + if (current && current.id === s.id) return; + busy = true; + const reduce = reducedMotion(); + const wasOpen = !!current; + current = s; + ev.emit('open', s); + render(s); + globe.resume(); + const focusing = globe.focus(s); + originCard?.classList.remove('hero-source'); + originCard = card; + + const media = card?.querySelector('.card-media'); + const T = targetRect(); + if (wasOpen || !media) { + app.classList.add('is-world'); + sheet.open('medium'); + busy = false; + haptic('light'); + return; + } + const C = media.getBoundingClientRect(); + const { el, img } = heroFor( + card.querySelector('img')?.currentSrc || s.thumbnail, + T, + ); + const nat = await imgSize(img); + const g = coverMath(nat, T, C); + Object.assign(img.style, { + width: `${g.iw}px`, + height: `${g.ih}px`, + left: `${g.left}px`, + top: `${g.top}px`, + }); + card.classList.add('hero-source'); + haptic('light'); + + if (reduce) { + el.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: 160, + fill: 'forwards', + }); + await wait(170); + app.classList.add('is-world'); + sheet.open('medium'); + await el.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 260, + fill: 'forwards', + }).finished; + el.remove(); + busy = false; + return; + } + + const dur = 620; + const scrim = h(''); + document.body.insertBefore(scrim, el); + scrim.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: dur * 0.7, + easing: EASE_OUT, + fill: 'forwards', + }); + el.animate( + [ + { clipPath: clipFor(T, C, 18) }, + { clipPath: `inset(0px 0px 0px 0px round ${isTwoPane() ? 0 : 0}px)` }, + ], + { duration: dur, easing: SPRING_SOFT, fill: 'forwards' }, + ); + const a = img.animate( + [ + { transform: `translate(${g.dx}px, ${g.dy}px) scale(${g.k})` }, + { transform: 'translate(0,0) scale(1)' }, + ], + { duration: dur, easing: SPRING_SOFT, fill: 'forwards' }, + ); + ev.emit('progress', 'morph'); + await a.finished; + card.classList.remove('hero-source'); + // The world is behind the photo now: reveal it. + app.classList.add('is-world'); + scrim.remove(); + sheet.open('medium'); + await Promise.race([focusing, wait(250)]); + const fade = el.animate( + [ + { opacity: 1, filter: 'blur(0px)' }, + { opacity: 0, filter: 'blur(6px)' }, + ], + { duration: 520, easing: EASE_OUT, fill: 'forwards' }, + ); + await fade.finished; + el.remove(); + busy = false; + } + + /* ---------- close ---------- */ + async function close({ fromSheet = false } = {}) { + if (!current || busy) return; + busy = true; + const s = current; + const reduce = reducedMotion(); + closingFromCode = true; + if (!fromSheet) sheet.close(); + closingFromCode = false; + globe.select(null); + let card = + originCard && document.contains(originCard) ? originCard : getCard(s.id); + const T = targetRect(); + const twoPane = isTwoPane(); + if (!card || twoPane || app.dataset.tab !== 'now') { + app.classList.remove('is-world'); + card?.classList.remove('hero-source'); + current = null; + originCard = null; + busy = false; + ev.emit('close', s); + return; + } + const { el, img } = heroFor( + card.querySelector('img')?.currentSrc || s.thumbnail, + T, + ); + const nat = await imgSize(img); + const fadeIn = el.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: reduce ? 160 : 260, + easing: EASE_OUT, + fill: 'forwards', + }); + await fadeIn.finished; + app.classList.remove('is-world'); + card.scrollIntoView({ block: 'nearest' }); + card.classList.add('hero-source'); + const C = card.querySelector('.card-media').getBoundingClientRect(); + const g = coverMath(nat, T, C); + Object.assign(img.style, { + width: `${g.iw}px`, + height: `${g.ih}px`, + left: `${g.left}px`, + top: `${g.top}px`, + }); + if (reduce) { + card.classList.remove('hero-source'); + await el.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 200, + fill: 'forwards', + }).finished; + } else { + const dur = 560; + el.animate( + [ + { clipPath: 'inset(0px 0px 0px 0px round 0px)' }, + { clipPath: clipFor(T, C, 18) }, + ], + { duration: dur, easing: SPRING, fill: 'forwards' }, + ); + await img.animate( + [ + { transform: 'translate(0,0) scale(1)' }, + { transform: `translate(${g.dx}px, ${g.dy}px) scale(${g.k})` }, + ], + { duration: dur, easing: SPRING, fill: 'forwards' }, + ).finished; + card.classList.remove('hero-source'); + } + el.remove(); + current = null; + originCard = null; + busy = false; + haptic('tick'); + ev.emit('close', s); + } + + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + + return { + open, + close, + sheet, + get current() { + return current; + }, + on: ev.on, + refresh() { + if (current) render(current); + }, + }; +} diff --git a/mobile/ui/you.js b/mobile/ui/you.js new file mode 100644 index 000000000..6c9dd6400 --- /dev/null +++ b/mobile/ui/you.js @@ -0,0 +1,163 @@ +/** You: captures and their seals, followed situations, holograms, settings. */ + +import { icon, FAMILY_ICON } from './icons.js'; +import { $, $$, esc, ago, haptic, store, applyMotionPref } from './dom.js'; +import { all, get } from '../capture/db.js'; +import { deviceKey } from '../capture/provenance.js'; + +export function createYou( + view, + { bindScroll, getSituations, onOpenSituation, onVerify, onCredits }, +) { + let urls = []; + view.addEventListener( + 'scroll', + () => view.classList.toggle('is-scrolled', view.scrollTop > 44), + { passive: true }, + ); + bindScroll(view); + + async function render() { + urls.forEach((u) => URL.revokeObjectURL(u)); + urls = []; + const [captures, key, sits] = await Promise.all([ + all('captures').catch(() => []), + deviceKey().catch(() => null), + getSituations().catch(() => []), + ]); + captures.sort((a, b) => b.seq - a.seq); + const followed = store.get('followed') || []; + const fol = followed + .map((id) => sits.find((s) => String(s.id) === id)) + .filter(Boolean); + const contacts = followed.filter((id) => id.startsWith('contact:')); + const thumbs = await Promise.all( + captures.slice(0, 30).map(async (c) => { + const m = await get('media', c.id).catch(() => null); + if (!m?.blob) return ''; + const u = URL.createObjectURL(m.blob); + urls.push(u); + return u; + }), + ); + const q = store.get('quality'); + const rm = store.get('reduceMotion'); + const radius = store.get('alertRadiusKm'); + view.innerHTML = ` + +

    You

    +
    ${icon('you')}
    This device + ${key ? `Signing key ${esc(key.keyId)} · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'} + ${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${followed.length} followed
    + +
    Captures tap to verify
    + ${ + captures.length + ? `
    ${captures + .slice(0, 30) + .map( + (c, i) => + ``, + ) + .join('')}
    ` + : `
    ${icon('seal')}
    No sealed captures yet.
    Open Capture → Witness and tap the shutter.
    ` + } + +
    Following
    + ${ + fol.length || contacts.length + ? `
    ${fol + .map( + (s) => + ``, + ) + .join( + '', + )}${contacts.map((c) => `
    ${icon('scope')}${esc(c.slice(8))}Contact
    `).join('')}
    ` + : '
    Follow a situation from its brief and it appears here and in the tab-bar accessory.
    ' + } + +
    Holograms Concept
    +
    ${icon('cube')}
    Leave something in a placePin a 3D note to a spot on Earth for people who visit with Look. Concept placeholder in this prototype.
    + +
    Settings
    +
    +
    ${icon('globe')}Globe qualityApplies on next launch
    +
    ${['auto', 'high', 'battery'].map((v) => ``).join('')}
    +
    ${icon('sparkles')}Reduce motionCross-fades instead of flights and morphs
    +
    ${['system', 'on', 'off'].map((v) => ``).join('')}
    +
    ${icon('pin')}Alert radiusSituations inside it ping you${radius.toLocaleString()} km
    +
    +
    ${icon('hand')}HapticsRibbon detents and seals only
    +
    + +
    About
    +
    + +
    ${icon('lock')}ProvenancePrototype uses WebCrypto ECDSA P-256 with a non-extractable key in IndexedDB. Native builds use the Secure Enclave and C2PA.
    +
    +

    God’s Eye mobile prototype. The window through which you see the world, and the eye through which the world sees itself.

    `; + $$('.segmented', view).forEach(placeThumb); + } + + function placeThumb(seg) { + const b = $('[aria-pressed="true"]', seg) || $('button', seg); + const th = $('.seg-thumb', seg); + if (!b || !th) return; + th.style.width = `${b.offsetWidth}px`; + th.style.transform = `translateX(${b.offsetLeft}px)`; + } + + view.addEventListener('click', async (e) => { + const cap = e.target.closest('[data-cap]'); + if (cap) { + const c = (await all('captures')).find((x) => x.id === cap.dataset.cap); + if (c) onVerify(c); + return; + } + const sit = e.target.closest('[data-sit]'); + if (sit) { + const s = (await getSituations()).find( + (x) => String(x.id) === sit.dataset.sit, + ); + if (s) onOpenSituation(s); + return; + } + const segBtn = e.target.closest('.segmented[data-set] button'); + if (segBtn) { + const seg = segBtn.parentElement; + $$('button', seg).forEach((b) => + b.setAttribute('aria-pressed', String(b === segBtn)), + ); + placeThumb(seg); + store.set(seg.dataset.set, segBtn.dataset.v); + if (seg.dataset.set === 'reduceMotion') applyMotionPref(); + haptic('tick'); + return; + } + const sw = e.target.closest('[data-switch]'); + if (sw) { + const on = sw.getAttribute('aria-checked') !== 'true'; + sw.setAttribute('aria-checked', String(on)); + store.set(sw.dataset.switch, on); + haptic('tick'); + return; + } + if (e.target.closest('[data-act="credits"]')) onCredits(); + }); + view.addEventListener('input', (e) => { + if (!e.target.matches('.slider')) return; + const v = Number(e.target.value); + e.target.style.setProperty('--p', `${((v - 25) / 1975) * 100}%`); + $('[data-radius]', view).textContent = `${v.toLocaleString()} km`; + }); + view.addEventListener('change', (e) => { + if (e.target.matches('.slider')) + store.set('alertRadiusKm', Number(e.target.value)); + }); + + new ResizeObserver(() => $$('.segmented', view).forEach(placeThumb)).observe( + view, + ); + return { render }; +} diff --git a/mobile/vision/assets/clouds.webp b/mobile/vision/assets/clouds.webp new file mode 100644 index 000000000..510d3abf9 Binary files /dev/null and b/mobile/vision/assets/clouds.webp differ diff --git a/mobile/vision/assets/day.webp b/mobile/vision/assets/day.webp new file mode 100644 index 000000000..82d693251 Binary files /dev/null and b/mobile/vision/assets/day.webp differ diff --git a/mobile/vision/assets/night.webp b/mobile/vision/assets/night.webp new file mode 100644 index 000000000..b2c5f2590 Binary files /dev/null and b/mobile/vision/assets/night.webp differ diff --git a/mobile/vision/assets/topo.webp b/mobile/vision/assets/topo.webp new file mode 100644 index 000000000..dae09792f Binary files /dev/null and b/mobile/vision/assets/topo.webp differ diff --git a/mobile/vision/assets/water.webp b/mobile/vision/assets/water.webp new file mode 100644 index 000000000..2b4cc1245 Binary files /dev/null and b/mobile/vision/assets/water.webp differ diff --git a/mobile/vision/build.mjs b/mobile/vision/build.mjs new file mode 100644 index 000000000..4653b4f2e --- /dev/null +++ b/mobile/vision/build.mjs @@ -0,0 +1,49 @@ +// Builds mobile/vision/index.html — one self-contained file (no runtime network). +// +// node mobile/vision/build.mjs # build once +// node mobile/vision/build.mjs --watch # rebuild on change +// +// Inputs (all offline, commit-friendly): +// src/app.html page template; markers are replaced +// src/app.css styles +// src/js/*.js concatenated in filename order inside one IIFE +// assets/*.webp NASA public-domain textures (made by prep-textures.mjs), inlined as data: URIs +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const rd = (p) => fs.readFileSync(path.join(here, p), 'utf8'); + +function build() { + const t0 = Date.now(); + const tpl = rd('src/app.html'); + const css = rd('src/app.css'); + const jsDir = path.join(here, 'src/js'); + const files = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js')).sort(); + const js = files.map((f) => `// ---- ${f}\n` + fs.readFileSync(path.join(jsDir, f), 'utf8')).join('\n'); + const tex = {}; + for (const f of fs.readdirSync(path.join(here, 'assets')).sort()) { + if (!/\.(webp|png|jpg)$/.test(f)) continue; + const ext = f.split('.').pop(); + const mime = ext === 'jpg' ? 'image/jpeg' : 'image/' + ext; + tex[f.replace(/\.\w+$/, '')] = `data:${mime};base64,` + fs.readFileSync(path.join(here, 'assets', f)).toString('base64'); + } + const texScript = ``; + const appScript = ``; + let out = tpl + .replace('', () => ``) + .replace('', () => texScript) + .replace('', () => appScript); + fs.writeFileSync(path.join(here, 'index.html'), out); + const kb = (Buffer.byteLength(out) / 1024).toFixed(0); + console.log(`built index.html ${kb} KB (${files.length} js files, ${Object.keys(tex).length} textures, ${Date.now() - t0} ms)`); +} + +build(); +if (process.argv.includes('--watch')) { + let timer; + for (const d of ['src', 'src/js', 'assets']) + fs.watch(path.join(here, d), () => { clearTimeout(timer); timer = setTimeout(() => { try { build(); } catch (e) { console.error(e); } }, 80); }); + console.log('watching…'); +} diff --git a/mobile/vision/index.html b/mobile/vision/index.html new file mode 100644 index 000000000..f08b7dd12 --- /dev/null +++ b/mobile/vision/index.html @@ -0,0 +1,4851 @@ + + + +God's Eye + + + +
    + +
    + +
    +
    +
    +
    +
    +
    + + +
    + +
    + + + + +
    +
    +
    +
    +
    +
    FORECAST
    +
    + +
    +
    + +
    +
    +
    + + +
    + + +
    +
    +
    +
    +
    Concept · illustrative data
    +
    + + +
    God’s Eye
    +
    The window to the world.
    +
    +
    + + diff --git a/mobile/vision/prep-textures.mjs b/mobile/vision/prep-textures.mjs new file mode 100644 index 000000000..472e8c971 --- /dev/null +++ b/mobile/vision/prep-textures.mjs @@ -0,0 +1,25 @@ +// One-time texture prep: NASA public-domain sources -> small WebP files in ./assets. +// Usage: node mobile/vision/prep-textures.mjs +// Sources (public domain, NASA): Blue Marble NG (cloudless day), Black Marble 2016 (night lights, +// https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km.jpg), +// Blue Marble clouds, land/water mask and topography (three-globe@2.31.0 example images). +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const require = createRequire(import.meta.url); +const sharp = require('sharp'); +const here = path.dirname(fileURLToPath(import.meta.url)); +const src = process.argv[2]; +if (!src) { console.error('usage: node prep-textures.mjs '); process.exit(1); } +const out = (f) => path.join(here, 'assets', f); +const jobs = [ + ['earth-blue-marble.jpg', 'day.webp', (s) => s.resize(4096, 2048).webp({ quality: 74, smartSubsample: true, effort: 6 })], + ['BlackMarble_2016_3km.jpg', 'night.webp', (s) => s.resize(4096, 2048, { kernel: 'lanczos3' }).webp({ quality: 74, smartSubsample: true, effort: 6 })], + ['clouds.png', 'clouds.webp', (s) => s.resize(2048, 1024).extractChannel('alpha').webp({ quality: 66, effort: 6 })], + ['earth-water.png', 'water.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 60, effort: 6 })], + ['earth-topology.png', 'topo.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 74, effort: 6 })], +]; +for (const [i, o, fn] of jobs) { + const info = await fn(sharp(path.join(src, i))).toFile(out(o)); + console.log(o, info.width + 'x' + info.height, (info.size / 1024).toFixed(0) + ' KB'); +} diff --git a/mobile/vision/src/app.css b/mobile/vision/src/app.css new file mode 100644 index 000000000..f33a7c2a0 --- /dev/null +++ b/mobile/vision/src/app.css @@ -0,0 +1,567 @@ +/* God's Eye — vision. Dark-first, Liquid-Glass-era shell. */ +:root { + color-scheme: dark; + --bg: #05070B; --bg2: #0A0E15; --card: #0C1118; --card2: #111723; + --text: #F3F6FB; --text2: rgba(232,238,248,.68); --text3: rgba(232,238,248,.44); --text4: rgba(232,238,248,.26); + --sep: rgba(255,255,255,.08); --stroke: rgba(255,255,255,.16); + --sky: #7CC4FF; --sea: #38E1C6; --orbit: #C9A7FF; --haz: #FF6A3D; --eyes: #FFD166; --ground: #9BE15D; --wx: #8AB4F8; --infra: #B8C0CC; + --gold: #E9C46A; --gold2: #F6DFA0; --live: #FF453A; --ok: #30D158; --warn: #FFD60A; --ai: #B79CFF; + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro", "Inter", system-ui, "Segoe UI", Roboto, sans-serif; + --display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter Display", "Inter", system-ui, sans-serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, "DejaVu Sans Mono", monospace; + --sat: env(safe-area-inset-top, 0px); --sab: env(safe-area-inset-bottom, 0px); --sal: env(safe-area-inset-left, 0px); --sar: env(safe-area-inset-right, 0px); + --paneW: 0px; --tabH: 62px; --spring: cubic-bezier(.2,.9,.25,1.12); --out: cubic-bezier(.16,1,.3,1); + --glass: rgba(24,29,38,.46); --glass-strong: rgba(16,20,28,.72); +} +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; background: var(--bg); overflow: hidden; overscroll-behavior: none; } +body { font: 15px/1.38 var(--font); color: var(--text); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; text-rendering: optimizeLegibility; font-feature-settings: "ss01", "cv11"; } +button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; } +button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; } +input, textarea { font: inherit; color: inherit; } +.num, .tnum { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } +.mono { font-family: var(--mono); font-size: .92em; letter-spacing: -.01em; } +.ic { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; overflow: visible; } +.ic.f { fill: currentColor; stroke: none; } +.ic.sm { width: 16px; height: 16px; stroke-width: 1.8; } .ic.xs { width: 13px; height: 13px; stroke-width: 2; } .ic.lg { width: 28px; height: 28px; } +.hide { display: none !important; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + +/* ---------- glass ---------- */ +.glass { position: relative; background: linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.025) 60%), var(--glass); -webkit-backdrop-filter: blur(24px) saturate(1.6); backdrop-filter: blur(24px) saturate(1.6); + box-shadow: inset 0 .5px 0 rgba(255,255,255,.34), inset 0 0 0 .5px rgba(255,255,255,.10), inset 0 -.5px 0 rgba(255,255,255,.05); } +.glass::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: radial-gradient(120% 60% at 20% -10%, rgba(255,255,255,.14), transparent 55%); mix-blend-mode: screen; } +.glass.strong { background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.02) 60%), var(--glass-strong); } +.pill { border-radius: 999px; } +.circ { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; } + +/* ---------- app & canvas ---------- */ +.app { position: fixed; inset: 0; overflow: hidden; background: var(--bg); } +#gl { position: absolute; left: var(--paneW); top: 0; width: calc(100% - var(--paneW)); height: 100%; display: block; touch-action: none; outline: none; } +.globe-area { position: absolute; left: var(--paneW); top: 0; right: 0; bottom: 0; pointer-events: none; } +.labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } +.lbl { position: absolute; left: 0; top: 0; transform: translate(-50%, -100%); pointer-events: auto; display: flex; align-items: center; gap: 6px; padding: 5px 10px 5px 7px; font-size: 12px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; border-radius: 999px; color: var(--text); transition: opacity .25s; } +.lbl i { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--sky)); box-shadow: 0 0 10px var(--c, var(--sky)); flex: none; } +.lbl small { font-weight: 500; color: var(--text2); font-size: 11px; } +.lbl.sel { box-shadow: inset 0 0 0 1px var(--gold), inset 0 .5px 0 rgba(255,255,255,.3); } +.lbl.plain { background: none; backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none; text-shadow: 0 1px 6px rgba(0,0,0,.9); padding: 2px 4px; font-weight: 600; } +.lbl.plain::after { display: none; } +.lbl.tag { transform: translate(10px, -50%); } + +/* ---------- views ---------- */ +.view { position: absolute; inset: 0; overflow: hidden auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: none; scrollbar-width: none; } +.view::-webkit-scrollbar { display: none; } +.view.on { display: block; animation: viewIn .38s var(--out); } +@keyframes viewIn { from { opacity: 0; transform: translateY(8px) scale(.995); } } +.pane { position: absolute; left: 0; top: 0; bottom: 0; width: 100%; } + +/* large-title nav */ +.navbar { position: sticky; top: 0; z-index: 5; height: calc(var(--sat) + 48px); padding-top: var(--sat); display: grid; place-items: center; font-weight: 600; font-size: 17px; opacity: 0; transition: opacity .2s; pointer-events: none; margin-bottom: calc(-48px - var(--sat)); } +.navbar.on { opacity: 1; pointer-events: auto; } +.navbar::before { content: ""; position: absolute; inset: 0; background: rgba(5,7,11,.62); -webkit-backdrop-filter: blur(20px) saturate(1.5); backdrop-filter: blur(20px) saturate(1.5); border-bottom: .5px solid var(--sep); z-index: -1; } +.lt { padding: calc(var(--sat) + 14px) 20px 6px; } +.lt .kicker { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--text3); display: flex; gap: 8px; align-items: center; } +.lt h1 { margin: 2px 0 0; font: 700 34px/41px var(--display); letter-spacing: .2px; } +.lt .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; } +.avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700; color: #fff; background: conic-gradient(from 200deg, hsl(var(--h,205) 80% 55%), hsl(calc(var(--h,205) + 60) 75% 45%), hsl(var(--h,205) 80% 55%)); box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.18); flex: none; } +.avatar.sm { width: 22px; height: 22px; font-size: 9px; } .avatar.md { width: 28px; height: 28px; font-size: 11px; } .avatar.xl { width: 72px; height: 72px; font-size: 26px; } +.avatar.ai { background: radial-gradient(circle at 30% 30%, #E6DBFF, #8E6CFF 55%, #4A2FB8); } +.stack { display: flex; } .stack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px var(--bg), inset 0 0 0 1.5px rgba(255,255,255,.18); } .stack .avatar:first-child { margin-left: 0; } + +.livebar { position: relative; z-index: 1; display: flex; gap: 14px; align-items: center; font-size: 13px; color: var(--text2); padding: 4px 20px 0; flex-wrap: wrap; } +.livebar b { color: var(--text); font-weight: 600; } +.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--live); display: inline-block; flex: none; } +.dot.pulse { animation: pulse 1.8s infinite; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,69,58,.55); } 70% { box-shadow: 0 0 0 7px rgba(255,69,58,0); } 100% { box-shadow: 0 0 0 0 rgba(255,69,58,0); } } +.chips { display: flex; gap: 8px; overflow-x: auto; padding: 12px 20px 6px; scrollbar-width: none; } +.chips::-webkit-scrollbar { display: none; } +.chip { flex: none; height: 34px; padding: 0 14px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); transition: background .2s, color .2s; white-space: nowrap; } +.chip.on { background: var(--text); color: #05070B; } +.chip .n { font-weight: 500; opacity: .6; } + +/* ---------- Now ---------- */ +#v-now { background: transparent; } +.now-hero { height: calc(var(--sat) + clamp(290px, 41vh, 400px)); position: relative; display: flex; flex-direction: column; justify-content: flex-end; pointer-events: none; } +.now-hero > * { pointer-events: auto; } +.now-hero::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 90px; background: linear-gradient(to bottom, rgba(5,7,11,0), var(--bg)); pointer-events: none; } +.now-hero .lt { position: relative; z-index: 1; padding-bottom: 0; text-shadow: 0 2px 18px rgba(0,0,0,.6); } +.now-top { position: absolute; top: calc(var(--sat) + 10px); left: 16px; right: 16px; display: flex; justify-content: space-between; align-items: center; z-index: 2; } +.now-body { background: var(--bg); min-height: 60vh; padding-bottom: calc(var(--sab) + 150px); } +.sec-h { display: flex; align-items: baseline; justify-content: space-between; padding: 18px 20px 10px; } +.sec-h h2 { margin: 0; font: 700 22px/28px var(--display); letter-spacing: .1px; } +.sec-h span { color: var(--text3); font-size: 13px; } +.masonry { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 12px; align-items: start; } +.masonry .col { display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.card { position: relative; border-radius: 18px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; transform: translateZ(0); transition: transform .35s var(--spring); } +.card:active { transform: scale(.975); } +.card .thumb { position: relative; width: 100%; background: #070A10; overflow: hidden; } +.card .thumb canvas, .card .thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; } +.card .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.28), transparent 26%, transparent 55%, rgba(0,0,0,.66)); pointer-events: none; } +.card .tl { position: absolute; top: 9px; left: 9px; z-index: 2; display: flex; gap: 6px; } +.badge { height: 22px; padding: 0 8px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: rgba(10,12,18,.5); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); } +.badge.live { color: #fff; } .badge.live .dot { width: 6px; height: 6px; } +.badge.fam i { width: 7px; height: 7px; border-radius: 50%; background: var(--c); box-shadow: 0 0 8px var(--c); } +.badge.d4 { color: var(--gold2); } .badge.replay { color: var(--text2); } +.card .tr { position: absolute; top: 9px; right: 9px; z-index: 2; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: rgba(10,12,18,.42); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); color: var(--c); } +.card .bl { position: absolute; left: 11px; right: 11px; bottom: 9px; z-index: 2; } +.card .metric { font: 700 21px/1.05 var(--display); letter-spacing: -.01em; text-shadow: 0 1px 10px rgba(0,0,0,.6); } +.card .metric small { font-size: 12px; font-weight: 600; color: var(--text2); margin-left: 3px; letter-spacing: 0; } +.card .mlabel { font-size: 11.5px; color: var(--text2); margin-top: 2px; text-shadow: 0 1px 8px rgba(0,0,0,.8); } +.card .meta { padding: 9px 11px 11px; } +.card .ttl { font-size: 14.5px; font-weight: 600; line-height: 1.28; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.card .src { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 7px; font-size: 11.5px; color: var(--text3); } +.card .src .l { display: flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.card .src .r { display: flex; align-items: center; gap: 4px; flex: none; } +.card .cta { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px; background: rgba(155,225,93,.14); color: var(--ground); font-size: 12.5px; font-weight: 700; } +.card.flash .thumb { animation: flash 1.2s ease; } +@keyframes flash { 30% { filter: brightness(1.35) saturate(1.2); } } +.card .spark { position: absolute; right: 10px; bottom: 12px; z-index: 2; opacity: .9; } + +/* ---------- tab bar & accessory ---------- */ +.tabwrap { position: absolute; left: 0; width: var(--tabW, 100%); bottom: 0; padding: 0 12px calc(var(--sab) + 10px); display: flex; gap: 10px; align-items: flex-end; justify-content: center; z-index: 40; pointer-events: none; transition: transform .45s var(--out), opacity .3s; } +.tabwrap > * { pointer-events: auto; } +.tabbar { height: var(--tabH); border-radius: 31px; display: flex; align-items: center; padding: 0 4px; gap: 0; transition: width .45s var(--out); } +.tab { position: relative; z-index: 1; width: 66px; height: 54px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-size: 10.5px; font-weight: 600; color: var(--text2); border-radius: 27px; transition: color .25s; letter-spacing: .01em; } +.tab .ic { width: 24px; height: 24px; } +.tab.on { color: var(--sky); } +.tablens { position: absolute; z-index: 0; top: 4px; height: 54px; width: 66px; border-radius: 27px; background: rgba(255,255,255,.12); box-shadow: inset 0 .5px 0 rgba(255,255,255,.3), inset 0 0 0 .5px rgba(255,255,255,.1); transition: transform .5s var(--spring), width .3s; } +.searchbtn { width: var(--tabH); height: var(--tabH); border-radius: 50%; display: grid; place-items: center; flex: none; } +.searchbtn .ic { width: 24px; height: 24px; } +.tabwrap.mini .tab:not(.on) { width: 0; opacity: 0; overflow: hidden; padding: 0; } +.tabwrap.mini .tab.on { width: 54px; } +.tabwrap.mini .tab.on span { display: none; } +.tabwrap.mini .tablens { width: 54px; } +.tabwrap.mini .tabbar { width: auto; } +.tabwrap.hidden { transform: translateY(140%); opacity: 0; pointer-events: none; } +.tabwrap.hidden > * { pointer-events: none; } +.accessory { height: 48px; border-radius: 24px; display: flex; align-items: center; gap: 10px; padding: 0 6px 0 14px; min-width: 0; flex: 1; max-width: 520px; transition: all .45s var(--out); } +.acc-host { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); display: flex; justify-content: center; z-index: 39; pointer-events: none; transition: transform .45s var(--out), opacity .3s; } +.acc-host > * { pointer-events: auto; } +.acc-host.hidden { opacity: 0; transform: translateY(30px); pointer-events: none; } +.acc-host.hidden > * { pointer-events: none; } +.accessory .ttl { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.accessory .sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.accessory .txt { min-width: 0; flex: 1; line-height: 1.2; } +.accessory .ab { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; } +.tabwrap .accessory { display: none; } +.tabwrap.mini .accessory { display: flex; } + +/* ---------- globe chrome ---------- */ +.gtop { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); right: 12px; display: flex; justify-content: space-between; align-items: flex-start; z-index: 20; pointer-events: none; } +.gtop > * { pointer-events: auto; } +.status { height: 40px; padding: 0 14px 0 12px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; } +.status .t { color: var(--text2); font-weight: 500; } +.gbtns { display: flex; flex-direction: column; gap: 10px; } +.gbtn { width: 44px; height: 44px; border-radius: 22px; display: grid; place-items: center; } +.gbtn.on { color: var(--sky); } +.compass .ic { transition: transform .1s linear; } +.gbottom { position: absolute; left: calc(var(--paneW) + 0px); right: 0; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); z-index: 20; display: flex; flex-direction: column; gap: 10px; padding: 0 12px; pointer-events: none; transition: transform .45s var(--out), opacity .3s; } +.gbottom > * { pointer-events: auto; } +.dock { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 2px; margin: -2px; } +.dock::-webkit-scrollbar { display: none; } +.fam { flex: none; height: 38px; padding: 0 13px 0 10px; border-radius: 19px; display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 600; color: var(--text); transition: opacity .2s, transform .3s var(--spring); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; } +.fam .ic { color: var(--c); width: 18px; height: 18px; } +.fam .n { color: var(--text3); font-weight: 500; font-size: 12px; } +.fam.off { opacity: .5; } .fam.off .ic { color: var(--text3); } +.fam.solo { box-shadow: inset 0 0 0 1.5px var(--c); } +.fam:active { transform: scale(.94); } + +/* time ribbon */ +.ribbon { height: 62px; border-radius: 22px; position: relative; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; } +.ribbon canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.ribbon .head { position: absolute; left: 50%; top: 6px; bottom: 6px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--text); box-shadow: 0 0 12px rgba(255,255,255,.6); pointer-events: none; z-index: 2; } +.ribbon .readout { position: absolute; left: 12px; top: 7px; font-size: 12px; font-weight: 600; z-index: 3; pointer-events: none; display: flex; gap: 6px; align-items: center; } +.ribbon .readout .rel { color: var(--text3); font-weight: 500; } +.ribbon .nowbtn { position: absolute; right: 6px; top: 6px; height: 26px; padding: 0 10px; border-radius: 13px; font-size: 11.5px; font-weight: 800; letter-spacing: .06em; z-index: 3; display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,.08); color: var(--text2); transition: all .25s; } +.ribbon .nowbtn.live { background: rgba(255,69,58,.18); color: #FF8A80; } +.ribbon .fc { position: absolute; right: 74px; top: 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: var(--wx); z-index: 3; opacity: 0; transition: opacity .2s; } +.ribbon.future .fc { opacity: 1; } +.ribbon.breath::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px rgba(255,69,58,.0); animation: breath 3.2s ease-in-out infinite; pointer-events: none; z-index: 1; } +@keyframes breath { 50% { box-shadow: inset 0 0 0 1px rgba(255,120,110,.32), inset 0 0 18px rgba(255,69,58,.12); } } + +/* pick card */ +.pick { border-radius: 26px; padding: 12px 12px 12px 14px; display: none; flex-direction: column; gap: 10px; animation: rise .4s var(--spring); } +.pick.on { display: flex; } +@keyframes rise { from { opacity: 0; transform: translateY(16px) scale(.98); } } +.pick .hd { display: flex; align-items: center; gap: 11px; } +.pick .ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); flex: none; } +.pick .tt { flex: 1; min-width: 0; } .pick .tt b { display: block; font-size: 16px; } .pick .tt span { font-size: 12.5px; color: var(--text2); } +.pick .x { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.08); color: var(--text2); } +.kv { display: flex; gap: 18px; font-size: 12px; color: var(--text3); } +.kv b { display: block; color: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; } +.acts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.act { height: 52px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; font-size: 11px; font-weight: 600; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); } +.act .ic { width: 20px; height: 20px; } +.act.pri { background: rgba(124,196,255,.18); color: var(--sky); } +.act.gold { background: rgba(233,196,106,.16); color: var(--gold2); } +.act:active { transform: scale(.95); } + +/* layer grid (Control Center) */ +.cc { position: absolute; inset: 0; z-index: 95; display: none; background: rgba(3,5,8,.38); -webkit-backdrop-filter: blur(30px) saturate(1.4) brightness(.8); backdrop-filter: blur(30px) saturate(1.4) brightness(.8); overflow: auto; padding: calc(var(--sat) + 14px) 16px calc(var(--sab) + 40px); } +.cc.on { display: block; animation: ccIn .42s var(--out); } +@keyframes ccIn { from { opacity: 0; transform: scale(1.04); } } +.cc .cch { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.cc .cch h2 { margin: 0; font: 700 28px/34px var(--display); } +.cc .cch p { margin: 2px 0 0; color: var(--text3); font-size: 13px; } +.lenses { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 6px 0 14px; } +.lens { border-radius: 16px; padding: 8px 4px 7px; display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); } +.lens .sw { width: 100%; height: 38px; border-radius: 11px; } +.lens.on { color: var(--text); box-shadow: inset 0 0 0 1.5px var(--sky); } +.ccfam { margin-top: 14px; } +.ccfam .fh { display: flex; align-items: center; gap: 8px; margin: 0 2px 8px; font-size: 13px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text2); user-select: none; -webkit-user-select: none; } +.ccfam .fh i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 10px var(--c); } +.ccfam .fh .solo { margin-left: auto; font-size: 11px; font-weight: 700; color: var(--text3); letter-spacing: .04em; padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,.06); } +.ccgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +.tile { border-radius: 20px; padding: 11px 12px; display: flex; gap: 10px; align-items: center; min-height: 64px; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); text-align: left; transition: background .2s, transform .2s var(--spring); } +.tile:active { transform: scale(.96); } +.tile .ti { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.1); color: var(--text2); flex: none; transition: all .2s; } +.tile .ti .ic { width: 19px; height: 19px; } +.tile .tn { font-size: 13px; font-weight: 600; line-height: 1.2; } +.tile .tc { font-size: 12px; color: var(--text3); margin-top: 2px; display: flex; align-items: center; gap: 5px; } +.tile .tc .st { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); } +.tile .tc .st.stale { background: transparent; box-shadow: inset 0 0 0 1px var(--text3); } +.tile.on { background: rgba(255,255,255,.92); color: #0A0D12; } +.tile.on .ti { background: var(--c); color: #071018; } +.tile.on .tc { color: rgba(10,13,18,.55); } + +/* ---------- sheet ---------- */ +.sheet { position: absolute; left: 0; right: 0; bottom: 0; height: calc(100% - var(--sat) - 8px); z-index: 30; border-radius: 30px 30px 0 0; transform: translateY(100%); transition: transform .5s var(--out); display: flex; flex-direction: column; will-change: transform; touch-action: none; } +.sheet.drag { transition: none; } +.sheet .grab { flex: none; height: 26px; display: grid; place-items: center; cursor: grab; } +.sheet .grab::before { content: ""; width: 38px; height: 5px; border-radius: 3px; background: rgba(255,255,255,.3); } +.sheet .sc { flex: 1; overflow: hidden auto; padding: 0 18px calc(var(--sab) + 30px); overscroll-behavior: contain; touch-action: pan-y; scrollbar-width: none; } +.sheet .sc::-webkit-scrollbar { display: none; } +.sh-hd { display: flex; align-items: flex-start; gap: 10px; } +.sh-hd .t { flex: 1; min-width: 0; } +.sh-hd h2 { margin: 6px 0 2px; font: 700 23px/28px var(--display); letter-spacing: -.005em; } +.sh-hd .pl { color: var(--text2); font-size: 13.5px; } +.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; margin: 14px 0 4px; } +.metric { padding: 0 6px 0 0; } +.metric .v { font: 700 26px/1.05 var(--display); font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.metric .v small { font-size: 13px; font-weight: 600; color: var(--text2); margin-left: 2px; letter-spacing: 0; } +.metric .l { font-size: 12px; color: var(--text2); margin-top: 3px; } +.metric .s { font-size: 10.5px; color: var(--text3); margin-top: 2px; } +.sh-acts { margin: 14px 0 6px; } +.brief { margin-top: 16px; } +.brief p { margin: 0 0 12px; font-size: 15px; line-height: 1.5; color: rgba(240,244,250,.86); } +.brief h3 { margin: 22px 0 8px; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); } +.sline { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; padding: 9px 0; border-top: .5px solid var(--sep); } +.sline .lab { font-size: 13.5px; font-weight: 600; } +.sline .val { font-size: 13.5px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; } +.sline svg { grid-column: 1 / -1; width: 100%; height: 46px; overflow: visible; } +.sline .src { grid-column: 1 / -1; font-size: 11px; color: var(--text3); display: flex; justify-content: space-between; } +.multi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.multi figure { margin: 0; } +.multi canvas, .multi svg { width: 100%; aspect-ratio: 1; border-radius: 10px; display: block; background: #070A10; } +.multi figcaption { font-size: 11px; color: var(--text2); margin-top: 4px; text-align: center; font-variant-numeric: tabular-nums; } +.multi figcaption b { color: var(--text); } +.evid { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 -18px; padding: 0 18px; } +.evid::-webkit-scrollbar { display: none; } +.ev { flex: none; width: 116px; border-radius: 14px; overflow: hidden; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); } +.ev .im { height: 84px; position: relative; background-size: cover; background-position: center; } +.ev .im .seal { position: absolute; right: 6px; bottom: 6px; } +.ev .cap { padding: 6px 8px 8px; font-size: 11px; color: var(--text2); line-height: 1.3; } +.ev .cap b { color: var(--text); font-weight: 600; display: block; } +.srcs { font-size: 12.5px; } +.srcs .r { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; padding: 8px 0; border-top: .5px solid var(--sep); } +.srcs .r .st { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); } +.srcs .r .st.stale { background: none; box-shadow: inset 0 0 0 1.2px var(--text3); } +.srcs .r span { color: var(--text3); font-variant-numeric: tabular-nums; } +.backbtn { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); z-index: 35; display: none; } +.backbtn.on { display: grid; } +.sitbtns { position: absolute; top: calc(var(--sat) + 10px); right: 12px; z-index: 35; display: none; gap: 10px; } +.sitbtns.on { display: flex; } +.seal { width: 22px; height: 22px; color: var(--gold); filter: drop-shadow(0 0 6px rgba(233,196,106,.6)); } + +/* morph */ +.morph { position: absolute; z-index: 50; overflow: hidden; pointer-events: none; display: none; will-change: transform, border-radius, opacity; } +.morph canvas { width: 100%; height: 100%; object-fit: cover; display: block; } +.morph.on { display: block; } + +/* ---------- ride HUD ---------- */ +.ride { position: absolute; inset: 0; z-index: 45; display: none; pointer-events: none; } +.ride.on { display: block; animation: viewIn .5s var(--out); } +.ride > * { pointer-events: auto; } +.ride .top { position: absolute; top: calc(var(--sat) + 10px); left: 12px; right: 12px; display: flex; gap: 10px; align-items: center; } +.ride .top .who { flex: 1; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 14px; font-size: 13px; } +.ride .top .who b { font-size: 14px; } +.hud { position: absolute; inset: 0; pointer-events: none !important; font-family: var(--mono); color: rgba(190,255,200,.9); text-shadow: 0 0 6px rgba(80,255,140,.5); } +.hud svg { position: absolute; inset: 0; width: 100%; height: 100%; } +.hud .tape { position: absolute; top: 50%; transform: translateY(-50%); font-size: 15px; font-weight: 600; padding: 6px 9px; border: 1px solid rgba(190,255,200,.55); border-radius: 6px; background: rgba(0,20,8,.25); } +.hud .tape small { display: block; font-size: 9.5px; opacity: .7; font-weight: 500; } +.chat { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 72px); height: 46%; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; pointer-events: none !important; mask-image: linear-gradient(to top, #000 70%, transparent); -webkit-mask-image: linear-gradient(to top, #000 70%, transparent); } +.bub { align-self: flex-start; max-width: 84%; display: flex; gap: 8px; align-items: flex-end; animation: bubIn .5s var(--spring); } +.bub .bx { padding: 8px 12px; border-radius: 18px 18px 18px 6px; font-size: 13.5px; line-height: 1.35; } +.bub .bx b { display: block; font-size: 11.5px; color: var(--text2); font-weight: 600; margin-bottom: 1px; } +@keyframes bubIn { from { opacity: 0; transform: translateY(20px) scale(.96); } } +.react { position: absolute; right: 16px; bottom: calc(var(--sab) + 130px); width: 40px; height: 300px; pointer-events: none !important; } +.react span { position: absolute; bottom: 0; left: 8px; animation: floatUp 2.6s ease-out forwards; color: var(--live); } +@keyframes floatUp { to { transform: translate(var(--dx, 0px), -280px) scale(1.2); opacity: 0; } } +.compose { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 12px); display: flex; gap: 8px; } +.compose .in { flex: 1; height: 48px; border-radius: 24px; display: flex; align-items: center; padding: 0 16px; color: var(--text3); font-size: 14px; } +.compose .in input { flex: 1; background: none; border: 0; outline: none; color: var(--text); font-size: 15px; min-width: 0; } + +/* ---------- capture ---------- */ +#v-capture { background: #000; z-index: 25; } +#v-capture.on { display: block; } +.cap-ui { position: absolute; inset: 0; pointer-events: none; } +.cap-ui > * { pointer-events: auto; } +.cap-top { position: absolute; top: calc(var(--sat) + 8px); left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.cap-top .st { height: 34px; padding: 0 12px; border-radius: 17px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 7px; } +.compass-tape { position: absolute; top: calc(var(--sat) + 52px); left: 0; right: 0; height: 40px; pointer-events: none !important; } +.callouts { position: absolute; inset: 0; pointer-events: none !important; } +.callout { position: absolute; left: 0; top: 0; pointer-events: auto; display: flex; align-items: center; gap: 8px; padding: 6px 11px 6px 8px; border-radius: 14px; font-size: 12px; transform: translate(14px, -50%); white-space: nowrap; } +.callout b { font-size: 13px; display: block; } +.callout span { color: var(--text2); font-size: 11px; } +.callout .ci { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 22%, transparent); color: var(--c); } +.reticle { position: absolute; width: 34px; height: 34px; margin: -17px 0 0 -17px; border: 1.5px solid var(--c, #fff); border-radius: 10px; pointer-events: none; box-shadow: 0 0 12px var(--c); } +.cap-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 16px calc(var(--sab) + 18px); display: flex; flex-direction: column; align-items: center; gap: 14px; background: linear-gradient(to top, rgba(0,0,0,.55), transparent); } +.zooms { display: flex; gap: 6px; padding: 4px; border-radius: 22px; } +.zooms button { width: 36px; height: 36px; border-radius: 50%; font-size: 12px; font-weight: 700; color: var(--text2); } +.zooms button.on { background: rgba(255,255,255,.14); color: var(--warn); } +.dial { display: flex; gap: 22px; font-size: 13px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text2); transition: transform .45s var(--spring); } +.dial button { padding: 6px 2px; } +.dial button.on { color: var(--warn); } +.shutrow { width: 100%; max-width: 440px; display: flex; align-items: center; justify-content: space-between; } +.shutter { width: 78px; height: 78px; border-radius: 50%; display: grid; place-items: center; box-shadow: 0 0 0 4px rgba(255,255,255,.92); transition: transform .2s; } +.shutter i { width: 64px; height: 64px; border-radius: 50%; background: #fff; transition: all .25s var(--spring); } +.shutter.look i { background: transparent; box-shadow: inset 0 0 0 2px rgba(255,255,255,.7); } +.shutter:active i { transform: scale(.88); } +.lastcap { width: 48px; height: 48px; border-radius: 12px; background: #111 center/cover; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); position: relative; } +.lastcap .seal { position: absolute; right: -6px; top: -6px; width: 20px; height: 20px; } +.sensors { position: absolute; left: 12px; bottom: calc(var(--sab) + 210px); border-radius: 16px; padding: 8px 10px; font-size: 11px; display: none; gap: 3px; flex-direction: column; min-width: 158px; color: var(--text2); } +.sensors.on { display: flex; } +.sensors b { color: var(--text); font-variant-numeric: tabular-nums; font-weight: 600; } +.sensors svg { width: 140px; height: 26px; } +.seal-ov { position: absolute; inset: 0; z-index: 70; display: none; align-items: center; justify-content: center; padding: calc(var(--sat) + 20px) 18px calc(var(--sab) + 20px); background: rgba(0,0,0,.55); -webkit-backdrop-filter: blur(18px) saturate(1.2); backdrop-filter: blur(18px) saturate(1.2); } +.seal-ov.on { display: flex; animation: viewIn .35s var(--out); } +.bundle { width: 100%; max-width: 420px; border-radius: 28px; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow: auto; } +.bundle .ph { position: relative; border-radius: 18px; overflow: hidden; aspect-ratio: 4/3; background: #000; } +.bundle .ph canvas, .bundle .ph img { width: 100%; height: 100%; object-fit: cover; display: block; } +.bigseal { position: absolute; right: 10px; bottom: 10px; width: 92px; height: 92px; opacity: 0; transform: scale(2.2) rotate(-40deg); transition: all .7s var(--spring); } +.bigseal.on { opacity: 1; transform: scale(1) rotate(0); } +.steps { display: flex; flex-direction: column; gap: 2px; } +.step { display: grid; grid-template-columns: 24px 1fr; gap: 8px; padding: 7px 2px; border-top: .5px solid var(--sep); font-size: 13px; opacity: .35; transition: opacity .3s; } +.step.on { opacity: 1; } +.step .ck { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px var(--text3); color: transparent; transition: all .3s; } +.step.done .ck { background: var(--gold); box-shadow: none; color: #1A1408; } +.step b { font-weight: 600; } .step .h { font-family: var(--mono); font-size: 11px; color: var(--text2); word-break: break-all; line-height: 1.35; margin-top: 2px; } +.step svg { width: 100%; height: 30px; margin-top: 4px; } +.btnrow { display: flex; gap: 8px; } +.btn { flex: 1; height: 50px; border-radius: 25px; font-weight: 700; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: rgba(255,255,255,.1); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.12); } +.btn.gold { background: linear-gradient(180deg, #F3D68A, #D9AE4F); color: #1C1405; box-shadow: inset 0 .5px 0 rgba(255,255,255,.6); } +.btn.pri { background: var(--text); color: #05070B; } +.btn:active { transform: scale(.97); } +.btn.sm { height: 38px; font-size: 13.5px; flex: none; padding: 0 16px; } + +/* ---------- Threads ---------- */ +.tcard { margin: 0 14px 14px; border-radius: 24px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; } +.tcard .thumb { position: relative; aspect-ratio: 16/9; background: #070A10; } +.tcard .thumb canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.tcard .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgba(0,0,0,.7)); } +.tcard .bl { position: absolute; left: 14px; bottom: 12px; right: 14px; z-index: 1; display: flex; justify-content: space-between; align-items: flex-end; gap: 10px; } +.tcard .bl h3 { margin: 0; font: 700 20px/1.15 var(--display); } +.tcard .bl p { margin: 3px 0 0; font-size: 12.5px; color: var(--text2); } +.tcard .stats { display: flex; gap: 16px; padding: 12px 14px; font-size: 12px; color: var(--text3); align-items: center; } +.tcard .stats b { color: var(--text); font-size: 15px; display: block; font-variant-numeric: tabular-nums; } +.tcard .stats .stack { margin-left: auto; } +.ws { position: absolute; inset: 0; display: none; z-index: 10; } +.ws.on { display: block; animation: pushIn .45s var(--out); } +@keyframes pushIn { from { transform: translateX(30%); opacity: 0; } } +.ws .wsh { position: absolute; top: 0; left: 0; right: 0; padding: calc(var(--sat) + 10px) 12px 0; display: flex; align-items: center; gap: 10px; z-index: 3; pointer-events: none; } +.ws .wsh > * { pointer-events: auto; } +.ws .wsh .tt { flex: 1; min-width: 0; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 8px 0 14px; } +.ws .wsh .tt b { font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; } +.ws .wsh .tt span { font-size: 11.5px; color: var(--text2); } +.ws .body { position: absolute; left: 0; right: 0; bottom: 0; top: 40%; border-radius: 28px 28px 0 0; display: flex; flex-direction: column; overflow: hidden; } +.ws .seg { display: flex; margin: 12px 14px 8px; padding: 3px; border-radius: 12px; background: rgba(255,255,255,.07); flex: none; } +.ws .seg button { flex: 1; height: 34px; border-radius: 9px; font-size: 13px; font-weight: 600; color: var(--text2); transition: all .25s; } +.ws .seg button.on { background: rgba(255,255,255,.16); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,.12) inset; } +.ws .wsc { flex: 1; overflow: auto; padding: 4px 14px calc(var(--sab) + 120px); scrollbar-width: none; } +.ws .wsc::-webkit-scrollbar { display: none; } +.stratum { font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 10px 2px 8px; display: flex; align-items: center; gap: 6px; } +.obs { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); } +.obs .oi { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); } +.obs .oi .ic { width: 18px; height: 18px; } +.obs .ot { font-size: 14px; font-weight: 600; line-height: 1.3; } +.obs .om { font-size: 11.5px; color: var(--text3); margin-top: 3px; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; } +.obs .om .mono { color: var(--text2); } +.obs .tagid { font-family: var(--mono); font-size: 11px; color: var(--gold2); background: rgba(233,196,106,.12); padding: 1px 6px; border-radius: 6px; } +.hyp { border-radius: 20px; padding: 13px 14px; margin: 0 0 10px; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); } +.hyp.ai { box-shadow: inset 0 0 0 1px rgba(183,156,255,.45); background: linear-gradient(180deg, rgba(183,156,255,.10), rgba(183,156,255,.02)), var(--card2); } +.hyp .hh { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); } +.hyp .hh b { color: var(--text); font-weight: 600; } +.hyp .hh .stt { margin-left: auto; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; padding: 3px 8px; border-radius: 999px; background: rgba(48,209,88,.14); color: #6BE38A; } +.hyp .hh .stt.disputed { background: rgba(255,214,10,.14); color: var(--warn); } +.hyp .hh .stt.proposed { background: rgba(183,156,255,.16); color: var(--ai); } +.hyp .hb { margin: 8px 0 10px; font-size: 15px; line-height: 1.42; } +.conf { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; font-size: 11.5px; color: var(--text3); } +.conf svg { width: 100%; height: 14px; } +.cites { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; } +.hyp .hf { display: flex; gap: 8px; margin-top: 10px; } +.vote { height: 34px; padding: 0 12px; border-radius: 17px; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; background: rgba(255,255,255,.07); color: var(--text2); } +.vote.on { background: rgba(124,196,255,.18); color: var(--sky); } +.vote.dis.on { background: rgba(255,214,10,.16); color: var(--warn); } +.composer { position: absolute; left: 10px; right: 10px; bottom: calc(var(--sab) + 10px); border-radius: 26px; padding: 8px; display: flex; flex-direction: column; gap: 8px; z-index: 5; } +.composer .sug { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; } +.composer .sug::-webkit-scrollbar { display: none; } +.composer .sug button { flex: none; height: 30px; padding: 0 11px; border-radius: 15px; font-size: 12px; font-weight: 600; background: rgba(183,156,255,.14); color: #D7C9FF; white-space: nowrap; } +.composer .row { display: flex; gap: 8px; align-items: center; } +.composer textarea { flex: 1; min-height: 40px; max-height: 96px; resize: none; background: rgba(255,255,255,.06); border: 0; outline: 0; border-radius: 20px; padding: 10px 14px; font-size: 14.5px; line-height: 1.35; color: var(--text); } +.composer .send { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--ai); color: #140B33; flex: none; } +.composer .send[disabled] { opacity: .5; } +.narr { display: flex; flex-direction: column; gap: 0; } +.beat { display: grid; grid-template-columns: 54px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); opacity: .55; transition: opacity .3s; cursor: pointer; } +.beat.on { opacity: 1; } +.beat .bt { font-family: var(--mono); font-size: 12px; color: var(--text2); padding-top: 2px; } +.beat.on .bt { color: var(--gold2); } +.beat p { margin: 0; font-size: 14.5px; line-height: 1.42; } +.playbar { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; } +.playbtn { width: 50px; height: 50px; border-radius: 50%; display: grid; place-items: center; background: var(--text); color: #05070B; flex: none; } +.prog { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,.12); position: relative; overflow: hidden; } +.prog i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--gold); border-radius: 2px; } +.ledger .le { border-top: .5px solid var(--sep); padding: 10px 2px; } +.ledger .le .lh { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; cursor: pointer; } +.ledger .le .ix { font-family: var(--mono); font-size: 11px; color: var(--text3); } +.ledger .le .ty { font-size: 13px; font-weight: 600; } +.ledger .le .ty span { color: var(--text3); font-weight: 500; } +.ledger .le .hs { font-family: var(--mono); font-size: 11px; color: var(--text2); } +.ledger .le .vk { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; } +.ledger .le .vk.ok { background: rgba(48,209,88,.2); color: #6BE38A; } .ledger .le .vk.bad { background: rgba(255,69,58,.25); color: #FF8A80; } +.ledger .le pre { margin: 8px 0 0 34px; font-family: var(--mono); font-size: 10.5px; line-height: 1.45; color: var(--text2); white-space: pre-wrap; word-break: break-all; background: rgba(255,255,255,.04); padding: 8px 10px; border-radius: 10px; display: none; } +.ledger .le.open pre { display: block; } +.ledger .chain { width: 2px; height: 10px; background: linear-gradient(var(--gold), transparent); margin-left: 12px; } +.presence { display: flex; align-items: center; gap: 8px; } +.presence .stack .avatar { box-shadow: 0 0 0 2px rgba(20,24,32,.9), inset 0 0 0 1.5px rgba(255,255,255,.2); } +.presence .on { position: relative; } +.presence .on::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 1.5px #10141c; } +.toastp { position: absolute; left: 50%; top: calc(var(--sat) + 62px); transform: translateX(-50%); z-index: 80; height: 40px; padding: 0 16px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; opacity: 0; transition: opacity .3s, transform .4s var(--spring); pointer-events: none; white-space: nowrap; } +.toastp.on { opacity: 1; transform: translateX(-50%) translateY(4px); } + +/* ---------- You ---------- */ +.you-h { display: flex; gap: 14px; align-items: center; padding: 6px 20px 4px; } +.you-h h2 { margin: 0; font: 700 22px/1.2 var(--display); } +.concept { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: 12px; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--gold2); background: rgba(233,196,106,.10); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.4); } +.stats4 { display: grid; grid-template-columns: repeat(4, 1fr); margin: 14px 16px 4px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); } +.stats4 div { padding: 12px 8px; text-align: center; font-size: 11.5px; color: var(--text3); } +.stats4 b { display: block; font: 700 21px/1.1 var(--display); color: var(--text); font-variant-numeric: tabular-nums; } +.grp { margin: 0 16px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); overflow: hidden; } +.row2 { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 52px; border-top: .5px solid var(--sep); font-size: 15px; } +.row2:first-child { border-top: 0; } +.row2 .ri { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 22%, transparent); color: var(--c, var(--sky)); flex: none; } +.row2 .ri .ic { width: 18px; height: 18px; } +.row2 .rt { flex: 1; min-width: 0; } .row2 .rt small { display: block; font-size: 12px; color: var(--text3); margin-top: 1px; } +.row2 .rv { color: var(--text3); font-size: 14px; font-variant-numeric: tabular-nums; } +.sw2 { width: 51px; height: 31px; border-radius: 16px; background: rgba(120,120,128,.32); position: relative; flex: none; transition: background .25s; } +.sw2::after { content: ""; position: absolute; left: 2px; top: 2px; width: 27px; height: 27px; border-radius: 50%; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.3); transition: transform .3s var(--spring); } +.sw2.on { background: var(--ok); } .sw2.on::after { transform: translateX(20px); } +.capgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px; } +.capc { border-radius: 16px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; } +.capc .im { aspect-ratio: 4/3; background: #111 center/cover; position: relative; } +.capc .im .seal { position: absolute; right: 8px; top: 8px; } +.capc .cm { padding: 8px 10px 10px; font-size: 12px; color: var(--text3); } +.capc .cm b { display: block; color: var(--text); font-size: 13px; font-weight: 600; margin-bottom: 2px; } +.capc .cm .mono { font-size: 10.5px; } +.radius { padding: 14px; } +.radius input[type=range] { width: 100%; accent-color: var(--sky); } +.radius svg { width: 100%; height: 120px; display: block; border-radius: 12px; background: #070A10; } +.credits { padding: 14px; font-size: 12.5px; line-height: 1.5; color: var(--text2); } +.credits b { color: var(--text); } +.inspector { position: absolute; inset: 0; z-index: 75; display: none; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.45); } +.inspector.on { display: flex; animation: viewIn .3s var(--out); } +.inspector .panel { width: 100%; max-width: 560px; border-radius: 30px 30px 0 0; padding: 10px 18px calc(var(--sab) + 18px); max-height: 88%; overflow: auto; } + +/* search */ +.search { position: absolute; inset: 0; z-index: 65; display: none; flex-direction: column; background: rgba(3,5,8,.5); -webkit-backdrop-filter: blur(28px) saturate(1.3); backdrop-filter: blur(28px) saturate(1.3); } +.search.on { display: flex; animation: viewIn .3s var(--out); } +.search .res { flex: 1; overflow: auto; padding: calc(var(--sat) + 16px) 16px 16px; display: flex; flex-direction: column; justify-content: flex-end; } +.search .grpH { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 14px 4px 6px; } +.search .it { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 14px; font-size: 15px; } +.search .it:active { background: rgba(255,255,255,.06); } +.search .it .ri { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 18%, transparent); color: var(--c, var(--sky)); flex: none; } +.search .it small { display: block; color: var(--text3); font-size: 12px; } +.search .bar { display: flex; gap: 10px; padding: 10px 12px calc(var(--sab) + 12px); } +.search .field { flex: 1; height: 52px; border-radius: 26px; display: flex; align-items: center; gap: 10px; padding: 0 16px; } +.search .field input { flex: 1; background: none; border: 0; outline: 0; font-size: 17px; color: var(--text); min-width: 0; } + +/* cold open */ +.cold { position: absolute; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding-bottom: calc(var(--sab) + 18vh); pointer-events: auto; transition: opacity 1s ease; } +.cold.out { opacity: 0; pointer-events: none; } +.cold .tag { font: 600 15px/1.4 var(--display); letter-spacing: .32em; text-transform: uppercase; color: rgba(255,255,255,.9); opacity: 0; transform: translateY(8px); transition: all 1.4s var(--out); text-align: center; padding: 0 20px; } +.cold .word { font: 700 44px/1 var(--display); letter-spacing: -.01em; margin-bottom: 14px; opacity: 0; transition: opacity 1.6s ease, letter-spacing 2.4s var(--out); letter-spacing: .12em; } +.cold.s1 .tag { opacity: 1; transform: none; } +.cold.s2 .word { opacity: 1; letter-spacing: -.01em; } +.cold .skip { position: absolute; top: calc(var(--sat) + 12px); right: 14px; height: 34px; padding: 0 14px; border-radius: 17px; font-size: 13px; font-weight: 600; color: var(--text2); } +.cold .eye { width: 60px; height: 60px; margin-bottom: 18px; opacity: 0; transition: opacity 1.2s; color: #fff; } +.cold.s2 .eye { opacity: .95; } +.concept-tag { position: absolute; z-index: 90; left: 14px; top: calc(var(--sat) + 58px); pointer-events: none; font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: rgba(246,223,160,.72); padding: 3px 7px; border-radius: 6px; background: rgba(10,12,18,.35); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.3); } +.app[data-tab="now"] .concept-tag, .app[data-tab="threads"] .concept-tag, .app[data-tab="you"] .concept-tag { top: calc(var(--sat) + 14px); left: auto; right: 14px; } +.app[data-tab="capture"] .concept-tag { top: calc(var(--sat) + 100px); left: 14px; } +.app[data-mode="situation"] .concept-tag { left: auto; right: 14px; top: calc(var(--sat) + 62px); } + +/* intro */ +.pane, .tabwrap, .acc-host, .concept-tag, .gchrome { transition: opacity .9s ease, transform .45s var(--out); } +.app.intro .pane, .app.intro .tabwrap, .app.intro .acc-host, .app.intro .concept-tag, .app.intro .gchrome, .app.intro .labels { opacity: 0 !important; pointer-events: none !important; } +.app.intro .pane * { pointer-events: none !important; } + +/* tab visibility (phone) */ +.app[data-tab="now"] .gchrome, .app[data-tab="threads"] .gchrome, .app[data-tab="you"] .gchrome, .app[data-tab="capture"] .gchrome { display: none; } +.app[data-mode="situation"] .gbottom .dock, .app[data-mode="ride"] .gchrome, .app[data-mode="ride"] .tabwrap, .app[data-mode="ride"] .acc-host { display: none; } +.app[data-tab="globe"] .acc-host { display: none; } +.app[data-mode="situation"] .gtop, .app[data-mode="workspace"] .gtop { opacity: 0; pointer-events: none; } +.app[data-mode="situation"] .gtop > * { pointer-events: none; } +.app[data-mode="situation"] .acc-host { display: none; } +.wide-only { display: none !important; } + +/* ---------- wide / foldable: two panes ---------- */ +@media (min-width: 600px) { + :root { --paneW: clamp(360px, 40vw, 440px); } + .pane { width: var(--paneW); background: var(--bg); border-right: .5px solid var(--sep); z-index: 22; } + .tabwrap { --tabW: var(--paneW); } + .acc-host { right: auto; width: calc(var(--paneW) - 24px); } + .app[data-tab="globe"] .acc-host { display: flex; } + .wide-only { display: block !important; } + .app .gchrome { display: block !important; } + .app .gchrome.flexy { display: flex !important; } + .now-hero { height: calc(var(--sat) + 150px); } + .now-hero::after { display: none; } + .now-hero .lt { text-shadow: none; } + .gbottom { bottom: 18px; padding: 0 16px; max-width: 760px; margin: 0 auto; left: var(--paneW); } + .sheet { left: 0; width: var(--paneW); height: 100%; border-radius: 0; transform: translateX(-104%); z-index: 34; background: var(--bg) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: inset -.5px 0 0 var(--sep) !important; } + .sheet::after { display: none; } + .sheet.wopen { transform: none !important; } + .sheet .grab { display: none; } + .sheet .sc { padding-top: calc(var(--sat) + 60px); } + .backbtn { left: 12px; } + .app[data-mode="situation"] .tabwrap { display: none; } + .ws .body { top: 0; border-radius: 0; background: var(--bg) !important; backdrop-filter: none !important; } + .ws .body::after { display: none; } + .ws .body { padding-top: calc(var(--sat) + 60px); } + .cc { left: var(--paneW); } + #v-capture { z-index: 45; } + .ride { left: var(--paneW); } + .concept-tag, .app[data-tab] .concept-tag { top: calc(var(--sat) + 14px); bottom: auto; left: auto; right: 70px; } + .composer { left: 10px; right: 10px; } +} +@media (min-width: 1100px) { + :root { --paneW: 440px; } + .masonry { grid-template-columns: 1fr 1fr; } +} +/* hinge-aware: vertical fold — panes split exactly at the hinge */ +@media (horizontal-viewport-segments: 2) { + :root { --paneW: env(viewport-segment-right 0 0, 50vw); } + #gl, .globe-area { left: env(viewport-segment-left 1 0, 50vw); width: auto; right: 0; } + #gl { width: calc(100% - env(viewport-segment-left 1 0, 50vw)); } + .gtop, .gbottom, .cc, .ride { left: env(viewport-segment-left 1 0, 50vw); } +} +/* tabletop: globe on the top half, controls on the bottom half */ +@media (vertical-viewport-segments: 2) { + :root { --paneW: 0px; --fold: env(viewport-segment-bottom 0 0, 50vh); --fold2: env(viewport-segment-top 0 1, 50vh); } + #gl { height: var(--fold); } + .pane { top: var(--fold2); width: 100%; border-right: 0; } + .now-hero { display: none; } + .gbottom { bottom: auto; top: calc(var(--fold2) + 12px); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .12s !important; } +} diff --git a/mobile/vision/src/app.html b/mobile/vision/src/app.html new file mode 100644 index 000000000..dc0cadfb3 --- /dev/null +++ b/mobile/vision/src/app.html @@ -0,0 +1,165 @@ + + + +God's Eye + + + +
    + +
    + +
    +
    +
    +
    +
    +
    + + +
    + +
    + + + + +
    +
    +
    +
    +
    +
    FORECAST
    +
    + +
    +
    + +
    +
    +
    + + +
    + + +
    +
    +
    +
    +
    Concept · illustrative data
    +
    + + +
    God’s Eye
    +
    The window to the world.
    +
    +
    + + diff --git a/mobile/vision/src/js/00-util.js b/mobile/vision/src/js/00-util.js new file mode 100644 index 000000000..219b01b92 --- /dev/null +++ b/mobile/vision/src/js/00-util.js @@ -0,0 +1,240 @@ +/* God's Eye — vision mockup. Utilities: math, geo, sun, rng, DOM, formatting. */ +const GE = (window.GE = window.GE || {}); +const D2R = Math.PI / 180, R2D = 180 / Math.PI, TAU = Math.PI * 2; +const EARTH_KM = 6371.0, EARTH_M = 6371000.0; +const clamp = (x, a, b) => (x < a ? a : x > b ? b : x); +const lerp = (a, b, t) => a + (b - a) * t; +const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; +const ease = { + inOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), + out: (t) => 1 - Math.pow(1 - t, 3), + outQuint: (t) => 1 - Math.pow(1 - t, 5), + inOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, + outBack: (t) => { const c1 = 1.4, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, +}; +const prefersReduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } }; + +/* ---------- vec3 / mat4 (Float64 arrays for CPU precision) ---------- */ +const v3 = { + add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]], + sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]], + scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s], + dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2], + cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]], + len: (a) => Math.hypot(a[0], a[1], a[2]), + norm: (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }, + lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t], + madd: (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s], +}; +const m4 = { + ident: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + mul(a, b) { // column-major a*b + const o = new Array(16); + for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) + o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]; + return o; + }, + persp(fovy, aspect, n, f) { + const t = 1 / Math.tan(fovy / 2); + return [t / aspect, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, (2 * f * n) / (n - f), 0]; + }, + // rotation-only view from basis (right, up, back) rows + viewRot(right, up, back) { + return [right[0], up[0], back[0], 0, right[1], up[1], back[1], 0, right[2], up[2], back[2], 0, 0, 0, 0, 1]; + }, + invert(m) { + const a = m, o = new Array(16); + const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3], a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; + const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11], a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; + const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11; + const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30; + const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32; + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (!det) return m4.ident(); + det = 1 / det; + o[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; o[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; + o[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; o[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det; + o[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; o[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; + o[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; o[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det; + o[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; o[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; + o[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; o[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det; + o[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; o[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; + o[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; o[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det; + return o; + }, + xform(m, p) { // returns clip vec4 + const x = p[0], y = p[1], z = p[2]; + return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14], m[3] * x + m[7] * y + m[11] * z + m[15]]; + }, +}; + +/* ---------- geo (unit sphere, x→(0,0), y→(0,90E), z→north) ---------- */ +const geo = { + vec(lat, lon, altKm = 0) { + const r = 1 + altKm / EARTH_KM, la = lat * D2R, lo = lon * D2R, c = Math.cos(la); + return [r * c * Math.cos(lo), r * c * Math.sin(lo), r * Math.sin(la)]; + }, + ll(v) { const r = v3.len(v); return [Math.asin(clamp(v[2] / r, -1, 1)) * R2D, Math.atan2(v[1], v[0]) * R2D, (r - 1) * EARTH_KM]; }, + enu(lat, lon) { // east, north, up + const la = lat * D2R, lo = lon * D2R; + const up = [Math.cos(la) * Math.cos(lo), Math.cos(la) * Math.sin(lo), Math.sin(la)]; + const east = [-Math.sin(lo), Math.cos(lo), 0]; + const north = [-Math.sin(la) * Math.cos(lo), -Math.sin(la) * Math.sin(lo), Math.cos(la)]; + return { east, north, up }; + }, + dist(lat1, lon1, lat2, lon2) { // radians + const p1 = lat1 * D2R, p2 = lat2 * D2R, dp = p2 - p1, dl = (lon2 - lon1) * D2R; + const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; + return 2 * Math.asin(Math.min(1, Math.sqrt(a))); + }, + bearing(lat1, lon1, lat2, lon2) { + const p1 = lat1 * D2R, p2 = lat2 * D2R, dl = (lon2 - lon1) * D2R; + return Math.atan2(Math.sin(dl) * Math.cos(p2), Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl)); + }, + slerp(a, b, t) { + const d = clamp(v3.dot(a, b), -1, 1), om = Math.acos(d); + if (om < 1e-9) return a.slice(); + const s = Math.sin(om); + return v3.add(v3.scale(a, Math.sin((1 - t) * om) / s), v3.scale(b, Math.sin(t * om) / s)); + }, + // destination from lat/lon, bearing (rad), distance (km) + dest(lat, lon, brg, km) { + const d = km / EARTH_KM, p1 = lat * D2R, l1 = lon * D2R; + const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(brg)); + const l2 = l1 + Math.atan2(Math.sin(brg) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2)); + return [p2 * R2D, ((l2 * R2D + 540) % 360) - 180]; + }, + // local ENU meters offset -> lat/lon + offset(lat, lon, eM, nM) { return [lat + (nM / EARTH_M) * R2D, lon + (eM / (EARTH_M * Math.cos(lat * D2R))) * R2D]; }, +}; + +/* ---------- sun & sidereal time ---------- */ +const astro = { + jd: (ms) => ms / 86400000 + 2440587.5, + // returns { lat, lon, dir:[x,y,z], gmst(rad) } + sun(ms) { + const d = astro.jd(ms) - 2451545.0; + const g = (357.529 + 0.98560028 * d) * D2R; + const q = 280.459 + 0.98564736 * d; + const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R; + const e = (23.439 - 0.00000036 * d) * D2R; + const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L)); + const dec = Math.asin(Math.sin(e) * Math.sin(L)); + const gmst = ((18.697374558 + 24.06570982441908 * d) % 24) * 15 * D2R; + let lon = (ra - gmst) * R2D; + lon = ((lon % 360) + 540) % 360 - 180; + const lat = dec * R2D; + return { lat, lon, dir: geo.vec(lat, lon), gmst: ((gmst % TAU) + TAU) % TAU }; + }, + // sun elevation (deg) at a place + elev(ms, lat, lon) { const s = astro.sun(ms); return 90 - geo.dist(lat, lon, s.lat, s.lon) * R2D; }, +}; + +/* ---------- seeded rng ---------- */ +function rng(seed) { + let a = seed >>> 0; + const f = () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; + f.range = (a0, b0) => a0 + (b0 - a0) * f(); + f.int = (n) => Math.floor(f() * n); + f.pick = (arr) => arr[Math.floor(f() * arr.length)]; + f.gauss = () => { let u = 0, v = 0; while (!u) u = f(); while (!v) v = f(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v); }; + f.wpick = (arr, w) => { let s = 0; for (const x of arr) s += w(x); let r = f() * s; for (const x of arr) { r -= w(x); if (r <= 0) return x; } return arr[arr.length - 1]; }; + return f; +} +const hash01 = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }; + +/* ---------- DOM ---------- */ +const $ = (s, r = document) => r.querySelector(s); +const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); +function h(tag, attrs, ...kids) { + const e = document.createElement(tag); + if (attrs) for (const k in attrs) { + const v = attrs[k]; + if (v == null || v === false) continue; + if (k === 'class') e.className = v; + else if (k === 'html') e.innerHTML = v; + else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); + else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); + else e.setAttribute(k, v === true ? '' : v); + } + for (const k of kids.flat()) if (k != null && k !== false) e.append(k.nodeType ? k : document.createTextNode(String(k))); + return e; +} +const ic = (name, cls = '') => ``; +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +/* ---------- formatting ---------- */ +const fmt = { + n(x, d = 0) { return Number(x).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); }, + k(x) { return x >= 1e6 ? (x / 1e6).toFixed(1) + 'M' : x >= 1e4 ? Math.round(x / 1e3) + 'k' : x >= 1e3 ? (x / 1e3).toFixed(1) + 'k' : String(Math.round(x)); }, + ago(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 5) return 'now'; + if (s < 60) return s + ' s ago'; + if (s < 3600) return Math.round(s / 60) + ' min ago'; + if (s < 86400) return Math.round(s / 3600) + ' h ago'; + return Math.round(s / 86400) + ' d ago'; + }, + utc(ms, withDay = true) { + const d = new Date(ms); + const dd = d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }); + const tt = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0'); + return withDay ? `${dd} · ${tt} UTC` : tt; + }, + hm(ms) { const d = new Date(ms); return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0'); }, + rel(h) { // hours offset → "−26 h" / "+4 h 30 m" + const sign = h < 0 ? '−' : '+', a = Math.abs(h), H = Math.floor(a), M = Math.round((a - H) * 60); + if (a < 1 / 60) return 'Live'; + return `${sign}${H ? H + ' h' : ''}${M && H < 24 ? (H ? ' ' : '') + M + ' m' : ''}`; + }, + ll(lat, lon, d = 3) { return `${Math.abs(lat).toFixed(d)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(d)}°${lon >= 0 ? 'E' : 'W'}`; }, + dur(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}`; }, + hex: (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join(''), +}; + +/* ---------- tiny event bus ---------- */ +const bus = { + m: new Map(), + on(e, f) { if (!this.m.has(e)) this.m.set(e, new Set()); this.m.get(e).add(f); return () => this.m.get(e).delete(f); }, + off(e, f) { this.m.get(e)?.delete(f); }, + emit(e, d) { this.m.get(e)?.forEach((f) => { try { f(d); } catch (err) { console.warn(err); } }); }, +}; + +/* ---------- storage (conveniences only) ---------- */ +const store = { + get(k, d) { try { const v = localStorage.getItem('ge.v1.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } }, + set(k, v) { try { localStorage.setItem('ge.v1.' + k, JSON.stringify(v)); } catch {} }, +}; +const haptic = (p = 8) => { try { navigator.vibrate && navigator.vibrate(p); } catch {} }; + +/* ---------- hashing ---------- */ +// SHA-256 fallback (only used if crypto.subtle is unavailable) +function sha256js(bytes) { + const K = new Uint32Array([0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]); + const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]); + const l = bytes.length, nb = ((l + 9 + 63) >> 6) << 6, m = new Uint8Array(nb); + m.set(bytes); m[l] = 0x80; const dv = new DataView(m.buffer); dv.setUint32(nb - 4, l * 8); dv.setUint32(nb - 8, Math.floor((l * 8) / 4294967296)); + const W = new Uint32Array(64); const r = (x, n) => (x >>> n) | (x << (32 - n)); + for (let o = 0; o < nb; o += 64) { + for (let i = 0; i < 16; i++) W[i] = dv.getUint32(o + i * 4); + for (let i = 16; i < 64; i++) { const s0 = r(W[i-15],7)^r(W[i-15],18)^(W[i-15]>>>3), s1 = r(W[i-2],17)^r(W[i-2],19)^(W[i-2]>>>10); W[i] = (W[i-16]+s0+W[i-7]+s1)|0; } + let [a,b,c,d,e,f,g,hh] = H; + for (let i = 0; i < 64; i++) { const S1=r(e,6)^r(e,11)^r(e,25), ch=(e&f)^(~e&g), t1=(hh+S1+ch+K[i]+W[i])|0, S0=r(a,2)^r(a,13)^r(a,22), mj=(a&b)^(a&c)^(b&c), t2=(S0+mj)|0; hh=g; g=f; f=e; e=(d+t1)|0; d=c; c=b; b=a; a=(t1+t2)|0; } + H[0]+=a; H[1]+=b; H[2]+=c; H[3]+=d; H[4]+=e; H[5]+=f; H[6]+=g; H[7]+=hh; + } + const out = new Uint8Array(32); const ov = new DataView(out.buffer); for (let i = 0; i < 8; i++) ov.setUint32(i * 4, H[i]); return out.buffer; +} +const hasSubtle = () => { try { return !!(window.crypto && crypto.subtle && crypto.subtle.digest); } catch { return false; } }; +async function sha256(data) { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data); + if (hasSubtle()) { try { return fmt.hex(await crypto.subtle.digest('SHA-256', bytes)); } catch {} } + return fmt.hex(sha256js(bytes)); +} +// canonical JSON (sorted keys) for hashing +function canon(o) { + if (o === null || typeof o !== 'object') return JSON.stringify(o); + if (Array.isArray(o)) return '[' + o.map(canon).join(',') + ']'; + return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + canon(o[k])).join(',') + '}'; +} + +Object.assign(GE, { D2R, R2D, TAU, EARTH_KM, clamp, lerp, smooth, ease, v3, m4, geo, astro, rng, fmt, bus, store, sha256, canon }); diff --git a/mobile/vision/src/js/10-data.js b/mobile/vision/src/js/10-data.js new file mode 100644 index 000000000..8ac22f095 --- /dev/null +++ b/mobile/vision/src/js/10-data.js @@ -0,0 +1,281 @@ +/* Synthetic, plausible world data. Real places & public phenomena; all people, handles, posts, + callsigns and reconstruction claims are illustrative (Concept · illustrative data). */ +const NOW = Date.now(); +GE.NOW = NOW; +const R = rng(20261018); + +/* ---------- airports [iata, city, lat, lon, weight] ---------- */ +const AIRPORTS = [ + ['ATL','Atlanta',33.6407,-84.4277,104],['DXB','Dubai',25.2532,55.3657,87],['DFW','Dallas–Fort Worth',32.8998,-97.0403,82],['LHR','London Heathrow',51.47,-0.4543,80], + ['HND','Tokyo Haneda',35.5494,139.7798,79],['DEN','Denver',39.8561,-104.6737,78],['IST','Istanbul',41.2753,28.7519,76],['LAX','Los Angeles',33.9416,-118.4085,75], + ['ORD','Chicago O’Hare',41.9742,-87.9073,74],['DEL','Delhi',28.5562,77.1,73],['CDG','Paris CDG',49.0097,2.5479,70],['JFK','New York JFK',40.6413,-73.7781,62], + ['CAN','Guangzhou',23.3924,113.2988,64],['PEK','Beijing',40.0799,116.6031,60],['PVG','Shanghai Pudong',31.1443,121.8083,62],['SIN','Singapore',1.3644,103.9915,59], + ['AMS','Amsterdam',52.3105,4.7683,62],['FRA','Frankfurt',50.0379,8.5622,60],['MAD','Madrid',40.4983,-3.5676,60],['BKK','Bangkok',13.69,100.7501,58], + ['ICN','Seoul Incheon',37.4602,126.4407,56],['BOM','Mumbai',19.0896,72.8656,52],['CGK','Jakarta',-6.1256,106.6559,50],['SFO','San Francisco',37.6213,-122.379,50], + ['LAS','Las Vegas',36.084,-115.1537,55],['MCO','Orlando',28.4312,-81.3081,57],['MIA','Miami',25.7959,-80.287,52],['CLT','Charlotte',35.214,-80.9431,53], + ['SEA','Seattle',47.4502,-122.3088,50],['PHX','Phoenix',33.4342,-112.0116,48],['EWR','Newark',40.6895,-74.1745,49],['IAH','Houston',29.9902,-95.3368,46], + ['BOS','Boston',42.3656,-71.0096,40],['MSP','Minneapolis',44.8848,-93.2223,36],['DTW','Detroit',42.2162,-83.3554,32],['YYZ','Toronto',43.6777,-79.6248,45], + ['YVR','Vancouver',49.1967,-123.1815,25],['MEX','Mexico City',19.4361,-99.0719,48],['CUN','Cancún',21.0365,-86.877,30],['GRU','São Paulo',-23.4356,-46.4731,42], + ['BOG','Bogotá',4.7016,-74.1469,38],['LIM','Lima',-12.0219,-77.1143,24],['SCL','Santiago',-33.393,-70.7858,24],['EZE','Buenos Aires',-34.8222,-58.5358,12], + ['GIG','Rio de Janeiro',-22.809,-43.2506,15],['PTY','Panama City',9.0714,-79.3835,17],['MUC','Munich',48.3537,11.775,41],['FCO','Rome',41.8003,12.2389,40], + ['BCN','Barcelona',41.2974,2.0833,52],['LGW','London Gatwick',51.1537,-0.1821,41],['ZRH','Zurich',47.4582,8.5555,31],['VIE','Vienna',48.1103,16.5697,29], + ['CPH','Copenhagen',55.618,12.6508,30],['OSL','Oslo',60.1976,11.1004,27],['ARN','Stockholm',59.6498,17.9238,25],['HEL','Helsinki',60.3172,24.9633,18], + ['DUB','Dublin',53.4264,-6.2499,33],['LIS','Lisbon',38.7742,-9.1342,33],['ATH','Athens',37.9364,23.9445,28],['WAW','Warsaw',52.1657,20.9671,21], + ['PRG','Prague',50.1008,14.26,17],['BRU','Brussels',50.9014,4.4844,24],['MAN','Manchester',53.3537,-2.275,29],['PMI','Palma',39.5517,2.7388,33], + ['DOH','Doha',25.2731,51.6081,45],['AUH','Abu Dhabi',24.433,54.6511,28],['RUH','Riyadh',24.9576,46.6988,36],['JED','Jeddah',21.6702,39.1525,42], + ['CAI','Cairo',30.1219,31.4056,30],['TLV','Tel Aviv',32.0055,34.8854,24],['ADD','Addis Ababa',8.9779,38.7993,13],['NBO','Nairobi',-1.3192,36.9278,9], + ['JNB','Johannesburg',-26.1392,28.246,21],['CPT','Cape Town',-33.9715,18.6021,11],['LOS','Lagos',6.5774,3.3212,8],['CMN','Casablanca',33.3675,-7.5898,10], + ['HKG','Hong Kong',22.308,113.9185,53],['TPE','Taipei',25.0797,121.2342,45],['NRT','Tokyo Narita',35.772,140.3929,33],['KIX','Osaka Kansai',34.4347,135.244,28], + ['MNL','Manila',14.5086,121.0198,45],['KUL','Kuala Lumpur',2.7456,101.7072,57],['SGN','Ho Chi Minh City',10.8188,106.6519,40],['HAN','Hanoi',21.2187,105.8042,29], + ['CTU','Chengdu',30.5785,103.9471,50],['SZX','Shenzhen',22.6393,113.8107,52],['KMG','Kunming',25.1019,102.9292,45],['XIY','Xi’an',34.4471,108.7516,43], + ['CKG','Chongqing',29.7192,106.6417,44],['HGH','Hangzhou',30.2295,120.4344,40],['BLR','Bengaluru',13.1986,77.7066,37],['MAA','Chennai',12.9941,80.1709,22], + ['CCU','Kolkata',22.6547,88.4467,22],['HYD','Hyderabad',17.2403,78.4294,21],['KTM','Kathmandu',27.6966,85.3591,8],['DAC','Dhaka',23.8433,90.3978,9], + ['KHI','Karachi',24.9065,67.1608,8],['SYD','Sydney',-33.9399,151.1753,44],['MEL','Melbourne',-37.669,144.841,37],['BNE','Brisbane',-27.3842,153.1175,24], + ['PER','Perth',-31.9385,115.9672,15],['AKL','Auckland',-37.0082,174.785,21],['HNL','Honolulu',21.3187,-157.9225,21],['ANC','Anchorage',61.1743,-149.9963,6], + ['SVO','Moscow',55.9726,37.4146,40],['LED','St Petersburg',59.8003,30.2625,19],['ALA','Almaty',43.3521,77.0405,9],['TAS','Tashkent',41.2579,69.2812,7], + ['RKV','Reykjavík',63.985,-22.6056,8],['SLC','Salt Lake City',40.7899,-111.9791,26],['SAN','San Diego',32.7338,-117.1933,25],['PDX','Portland',45.5898,-122.5951,19], + ['AUS','Austin',30.1975,-97.6664,21],['BNA','Nashville',36.1263,-86.6774,22],['MSY','New Orleans',29.9934,-90.258,13],['YUL','Montréal',45.4706,-73.7408,21], + ['YYC','Calgary',51.1215,-114.0076,18],['GUA','Guatemala City',14.5833,-90.5275,3],['SJO','San José CR',9.9939,-84.2088,5],['UIO','Quito',-0.1292,-78.3575,5], +]; +const AP = AIRPORTS.map(([iata, city, lat, lon, w]) => ({ iata, city, lat, lon, w, v: geo.vec(lat, lon) })); +const apByIata = Object.fromEntries(AP.map((a) => [a.iata, a])); + +/* ---------- fictional operators ---------- */ +const OPS = ['AXR','NVA','SKL','ORX','VLT','KTO','LMA','TRV','QNX','PLR','HZN','CRS','SOL','MRD','EQX','BLU','CYG','ALD','JTR','FRN']; +const OPNAME = { AXR:'Axiom Air', NVA:'Nova Atlantic', SKL:'Skylark', ORX:'Orix Cargo', VLT:'Volta', KTO:'Kito Air', LMA:'Lumea', TRV:'Traverse', QNX:'Quanta', PLR:'Polaris', HZN:'Horizonte', CRS:'Cirrus Lines', SOL:'Solstice', MRD:'Meridian', EQX:'Equinox', BLU:'Bluewing', CYG:'Cygnet', ALD:'Aldea', JTR:'Jetrail', FRN:'Fernway' }; +const TYPES_SHORT = ['A320','A21N','B738','B38M','E190','A20N','AT76','CRJ9']; +const TYPES_LONG = ['B77W','A359','B789','A388','B78X','A35K','B744F','A333']; + +/* ---------- flights (GPU-animated great circles) ---------- + Each flight cycles: fly a→b for dur, turn around, fly b→a… so any scrub time is consistent. */ +const FLIGHTS = []; +(function genFlights() { + const N = 3600; + const wsum = AP.reduce((s, a) => s + a.w, 0); + const pickAp = () => { let r = R() * wsum; for (const a of AP) { r -= a.w; if (r <= 0) return a; } return AP[0]; }; + let guard = 0; + while (FLIGHTS.length < N && guard++ < N * 8) { + const a = pickAp(); let b = pickAp(); + if (a === b) continue; + const d = geo.dist(a.lat, a.lon, b.lat, b.lon) * EARTH_KM; + if (d < 250 || d > 15500) continue; + // short-haul bias + if (d > 6000 && R() < 0.55) continue; + const long = d > 4500; + const speed = long ? 905 : 780; // km/h + const dur = (d / speed + 0.45) * 3600; // seconds + const turn = (long ? R.range(1.5, 3.5) : R.range(0.6, 2.2)) * 3600; + const period = 2 * (dur + turn); + const op = R.pick(OPS); + FLIGHTS.push({ + kind: 0, a, b, dKm: d, dur, turn, period, t0: R() * period, alt: long ? R.range(10.6, 12.5) : R.range(8.5, 11.6), + cs: op + (R.int(8900) + 100), op, type: long ? R.pick(TYPES_LONG) : R.pick(TYPES_SHORT), speed, + }); + } + // private / GA — short hops near busy regions + for (let i = 0; i < 380; i++) { + const a = R.pick(AP); const brg = R() * TAU, km = R.range(80, 520); + const [lat, lon] = geo.dest(a.lat, a.lon, brg, km); + const b = { iata: 'GA', city: '', lat, lon, w: 1, v: geo.vec(lat, lon) }; + const dur = (km / 330 + 0.2) * 3600, turn = R.range(0.5, 4) * 3600; + FLIGHTS.push({ kind: 3, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(1.5, 4.5), cs: 'N' + (R.int(899) + 100) + String.fromCharCode(65 + R.int(26), 65 + R.int(26)), op: 'GA', type: R.pick(['C172','PC12','SR22','C25A','BE20']), speed: 330 }); + } + // military transits (fictional callsigns) + const MIL = [[55.0,10.0],[50.0,20.0],[44.0,30.0],[37.0,24.0],[36.5,-76.0],[32.0,-110.0],[26.0,51.0],[35.0,128.0],[21.0,-158.0],[13.5,144.8],[51.0,-1.5],[64.0,25.0],[57.0,22.0],[54.5,18.0]]; + const MCS = ['VIPER','TITAN','HAWK','RAVEN','SABRE','ATLAS','GHOST','DUKE','NOMAD','LANCE']; + for (let i = 0; i < 160; i++) { + const c = R.pick(MIL); const [la1, lo1] = geo.dest(c[0], c[1], R() * TAU, R.range(50, 600)); const [la2, lo2] = geo.dest(c[0], c[1], R() * TAU, R.range(300, 1600)); + const a = { iata: 'MIL', lat: la1, lon: lo1, v: geo.vec(la1, lo1) }, b = { iata: 'MIL', lat: la2, lon: lo2, v: geo.vec(la2, lo2) }; + const km = geo.dist(la1, lo1, la2, lo2) * EARTH_KM; const dur = (km / 760 + 0.2) * 3600, turn = R.range(0.3, 3) * 3600; + FLIGHTS.push({ kind: 1, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(6, 11), cs: R.pick(MCS) + ' ' + (R.int(89) + 10), op: 'MIL', type: R.pick(['KC-135','C-17','P-8','E-3','RC-135','A400','F-35','MQ-9']), speed: 760 }); + } +})(); +// position of a flight at sim seconds t → {v:[x,y,z] (unit, alt applied), lat, lon, altKm, hdg, prog, onLeg} +function flightAt(f, t) { + let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period; + const leg = ph < f.period / 2 ? 0 : 1; const lp = ph - leg * (f.period / 2); + if (lp > f.dur) return null; + const p = lp / f.dur; const A = leg ? f.b : f.a, B = leg ? f.a : f.b; + const s = geo.slerp(A.v, B.v, p); + const climb = Math.min(1, Math.min(p, 1 - p) * f.dKm / 160); + const altKm = f.alt * Math.sqrt(Math.max(0, climb)); + const [lat, lon] = geo.ll(s); + const s2 = geo.slerp(A.v, B.v, Math.min(1, p + 0.001)); const [lat2, lon2] = geo.ll(s2); + return { v: s, lat, lon, altKm, hdg: geo.bearing(lat, lon, lat2, lon2), prog: p, from: A, to: B }; +} + +/* ---------- shipping lanes (lat,lon chains on water) ---------- */ +const LANES = [ + // Asia–Europe via Suez + [[31.0,122.6],[28.0,122.6],[24.6,119.9],[22.0,116.0],[18.0,113.5],[12.0,110.4],[7.0,106.0],[2.6,104.9],[1.25,104.0],[1.9,102.4],[3.4,100.6],[5.9,97.2],[6.1,94.0],[6.0,88.0],[5.7,81.0],[7.0,76.5],[11.0,64.0],[12.2,52.0],[12.0,46.5],[12.6,43.4],[14.5,42.4],[18.0,40.3],[22.0,37.9],[25.5,35.3],[27.8,33.7],[29.6,32.6],[30.6,32.33],[31.4,32.33],[32.6,30.0],[34.0,24.5],[36.1,16.0],[37.3,11.6],[37.6,8.0],[36.6,2.0],[35.95,-5.6],[36.6,-9.6],[40.0,-10.2],[43.6,-9.9],[48.4,-5.8],[49.9,-2.3],[50.9,1.3],[51.6,2.8],[52.0,3.95]], + // Transpacific (Shanghai → LA) + [[31.0,122.6],[30.3,128.5],[33.0,135.5],[34.6,139.9],[36.5,152.0],[38.0,165.0],[38.5,180.0],[37.5,-165.0],[35.5,-145.0],[34.0,-128.0],[33.7,-118.4]], + // Busan → Seattle (great circle north) + [[35.0,129.1],[36.5,135.0],[41.4,141.6],[45.0,155.0],[50.0,175.0],[51.5,-170.0],[51.0,-150.0],[49.0,-132.0],[48.4,-124.8]], + // Transatlantic + [[49.9,-2.3],[49.4,-6.4],[47.5,-20.0],[44.5,-40.0],[41.5,-60.0],[40.45,-73.8]], + // US East coast → Panama → west coast S America + [[40.45,-73.8],[35.2,-74.6],[28.0,-77.5],[24.0,-74.5],[20.2,-73.8],[15.0,-77.0],[9.4,-79.9],[8.9,-79.5],[7.2,-80.2],[1.0,-81.3],[-6.0,-81.5],[-12.0,-77.3]], + // Panama → LA + [[8.9,-79.5],[10.5,-87.5],[15.0,-96.5],[20.0,-106.0],[23.0,-110.6],[29.0,-116.0],[33.7,-118.4]], + // Gulf of Mexico → Florida → NY + [[29.3,-94.7],[26.5,-90.0],[24.3,-84.0],[24.2,-81.0],[26.5,-79.7],[31.0,-79.5],[35.2,-74.6]], + // Persian Gulf → Hormuz → Arabian Sea → Gulf of Aden + [[29.4,48.6],[27.3,50.8],[26.5,54.0],[26.5,56.4],[25.2,57.5],[22.6,60.3],[17.0,57.0],[12.2,52.0]], + // Persian Gulf → Malacca + [[25.2,57.5],[22.6,60.3],[17.0,66.0],[10.0,73.0],[6.6,79.3],[5.7,81.0]], + // Cape route + [[22.6,60.3],[10.0,58.0],[-5.0,52.0],[-12.0,45.5],[-20.0,39.5],[-28.0,34.5],[-34.5,26.0],[-35.3,19.5],[-33.0,15.5],[-24.0,11.0],[-10.0,4.0],[2.0,-3.0],[10.0,-18.5],[20.0,-19.5],[28.5,-15.0],[35.5,-10.0],[36.6,-9.6]], + // Singapore → Australia (Lombok) + [[1.25,104.0],[-2.0,107.4],[-6.0,113.0],[-8.7,115.75],[-14.0,117.0],[-20.3,118.6]], + // SE Asia → Hong Kong → Busan + [[22.0,116.0],[25.0,121.8],[29.0,124.0],[33.0,127.8],[35.0,129.1]], + // North Sea → Baltic + [[52.0,3.95],[53.8,6.8],[56.2,7.6],[57.9,10.7],[56.4,12.0],[55.6,12.75],[55.1,14.8],[56.5,18.6],[58.3,20.6],[59.6,23.5],[59.95,27.5],[59.9,29.8]], + // Med: Port Said → Piraeus → Bosphorus → Novorossiysk + [[31.4,32.33],[33.5,28.5],[36.0,25.3],[37.9,23.55],[39.2,24.6],[40.2,26.2],[40.95,28.9],[41.25,29.1],[42.8,31.5],[44.3,36.5],[44.65,37.8]], + // W Med: Gibraltar → Marseille → Genoa + [[35.95,-5.6],[37.0,-1.0],[39.5,2.6],[42.6,4.6],[43.2,5.3],[43.6,7.5],[44.3,8.9]], + // China coast → Japan + [[31.0,122.6],[33.0,126.0],[34.0,130.8],[33.6,135.0],[34.6,139.9]], + // Australia east coast + [[-33.85,151.4],[-30.0,153.8],[-27.3,153.6],[-24.0,152.8],[-19.0,147.8],[-12.0,143.8],[-10.4,142.1],[-9.8,137.0],[-8.6,125.0],[-8.7,115.75]], + // Brazil → West Africa + [[-23.9,-46.3],[-22.9,-41.0],[-15.0,-37.5],[-5.0,-34.4],[2.0,-25.0],[6.0,-8.0],[6.3,3.4]], +]; +const LANE_V = LANES.map((l) => { const pts = l.map(([la, lo]) => geo.vec(la, lo)); const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.acos(clamp(v3.dot(pts[i - 1], pts[i]), -1, 1))); return { pts, cum, len: cum[cum.length - 1], raw: l }; }); +const SHIPS = []; +(function genShips() { + const NAMES1 = ['Northern','Aurora','Pacific','Silver','Atlas','Coral','Ocean','Polar','Cape','Golden','Blue','Iron','Jade','Amber','Crimson','Morning','Evening','Sea','Star','Harbor']; + const NAMES2 = ['Lark','Spirit','Voyager','Pioneer','Horizon','Dawn','Tide','Meridian','Wave','Crest','Falcon','Unity','Glory','Venture','Breeze','Pearl','Fortune','Harmony','Trader','Express']; + const kinds = ['container','tanker','bulk','LNG','ro-ro','general']; + const tot = LANE_V.reduce((s, l) => s + l.len, 0); + for (let i = 0; i < 2200; i++) { + let r = R() * tot, li = 0; for (; li < LANE_V.length - 1; li++) { if (r < LANE_V[li].len) break; r -= LANE_V[li].len; } + const kn = kinds[R.int(kinds.length)]; + const kts = kn === 'container' ? R.range(14, 21) : R.range(10, 15); + SHIPS.push({ lane: li, off: R(), dir: R() < 0.5 ? 1 : -1, speed: (kts * 1.852) / EARTH_KM / 3600, lat0: R.gauss() * 0.12, name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: kn, mmsi: 200000000 + R.int(500000000), dark: R() < 0.012 }); + } + // anchorage queues: Suez south & north, Singapore, Rotterdam, LA/LB + const Q = [[29.72,32.56,48,0.09,'suez'],[31.45,32.2,30,0.1,'suez'],[1.22,103.9,90,0.12,'sin'],[52.0,3.6,26,0.12,'rtm'],[33.55,-118.2,22,0.08,'lalb'],[29.1,48.6,18,0.15,''],[22.2,113.9,40,0.12,'']]; + for (const [la, lo, n, s, q] of Q) for (let i = 0; i < n; i++) SHIPS.push({ anchor: [la + R.gauss() * s * 0.6, lo + R.gauss() * s], name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: R.pick(kinds), mmsi: 200000000 + R.int(500000000), queue: q, speed: 0 }); +})(); +function shipAt(s, t) { + if (s.anchor) { const [la, lo] = s.anchor; return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: hash01(s.mmsi) * TAU, sog: 0 }; } + const L = LANE_V[s.lane]; + let d = (s.off * L.len + s.dir * s.speed * t) % L.len; if (d < 0) d += L.len; + let i = 1; while (i < L.cum.length - 1 && L.cum[i] < d) i++; + const seg = L.cum[i] - L.cum[i - 1] || 1; const p = (d - L.cum[i - 1]) / seg; + let v = geo.slerp(L.pts[i - 1], L.pts[i], p); + const [la0, lo0] = geo.ll(v); + const brg = geo.bearing(...L.raw[i - 1], ...L.raw[i]) + (s.dir < 0 ? Math.PI : 0); + const [la, lo] = geo.dest(la0, lo0, brg + Math.PI / 2, s.lat0 * 18); + return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: brg, sog: s.speed * EARTH_KM * 3600 / 1.852 }; +} + +/* ---------- satellites (circular orbits; GPU-propagated) ---------- */ +const MU = 398600.4418; +const SATS = []; +function addSat(o) { const a = EARTH_KM + o.alt; o.n = Math.sqrt(MU / (a * a * a)); SATS.push(o); return o; } +(function genSats() { + // Starlink-like shells (53°, 550 km), (70°, 570 km), (97.6°, 560 km) + const shells = [[53, 550, 22, 36], [70, 570, 12, 20], [97.6, 560, 10, 18], [43, 540, 14, 22]]; + for (const [inc, alt, planes, per] of shells) for (let p = 0; p < planes; p++) for (let k = 0; k < per; k++) + addSat({ name: 'STARLINK-' + (1000 + SATS.length), inc: inc * D2R, raan: (p / planes) * TAU, u0: (k / per) * TAU + p * 0.13, alt: alt + R.range(-4, 4), kind: 0 }); + // other LEO (EO, weather, cubesats) + for (let i = 0; i < 420; i++) addSat({ name: 'LEO-' + (40000 + i), inc: (R() < 0.5 ? R.range(96.5, 99) : R.range(30, 82)) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(380, 1200), kind: 1 }); + // GNSS MEO + for (let i = 0; i < 31; i++) addSat({ name: 'GPS ' + (i + 1), inc: 55 * D2R, raan: (Math.floor(i / 5.2) / 6) * TAU, u0: (i % 6) / 6 * TAU + i, alt: 20180, kind: 2 }); + for (let i = 0; i < 24; i++) addSat({ name: 'GNSS-E ' + (i + 1), inc: 56 * D2R, raan: (i % 3) / 3 * TAU, u0: i / 8 * TAU, alt: 23222, kind: 2 }); + // GEO belt + for (let i = 0; i < 90; i++) addSat({ name: 'GEO-' + (i + 1), inc: R.range(0, 0.08) * D2R, raan: 0, u0: R() * TAU, alt: 35786, kind: 3, geo: true }); + // debris + for (let i = 0; i < 600; i++) addSat({ name: 'DEB ' + (20000 + i), inc: R.range(60, 100) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(650, 1500), kind: 4 }); + // Starlink train (fresh launch, low and tight) + for (let i = 0; i < 24; i++) addSat({ name: 'STARLINK-T' + (i + 1), inc: 43 * D2R, raan: 1.9, u0: 2.4 + i * 0.006, alt: 312, kind: 5 }); +})(); +const ISS = addSat({ name: 'ISS (ZARYA)', inc: 51.64 * D2R, raan: 4.1, u0: 0.7, alt: 418, kind: 6 }); +const CSS = addSat({ name: 'CSS (TIANHE)', inc: 41.47 * D2R, raan: 2.2, u0: 3.1, alt: 385, kind: 6 }); +function satAt(s, t, gmst) { // ECEF unit vector with altitude + const u = s.u0 + s.n * t, r = 1 + s.alt / EARTH_KM; + const cu = Math.cos(u), su = Math.sin(u), cO = Math.cos(s.raan), sO = Math.sin(s.raan), ci = Math.cos(s.inc), si = Math.sin(s.inc); + let x = cu * cO - su * ci * sO, y = cu * sO + su * ci * cO, z = su * si; + if (s.geo) { const g = gmst; const c = Math.cos(s.u0), sn = Math.sin(s.u0); return [r * c, r * sn, z * r]; } + const cg = Math.cos(-gmst), sg = Math.sin(-gmst); + return [r * (x * cg - y * sg), r * (x * sg + y * cg), r * z]; +} + +/* ---------- quakes (illustrative, along plate boundaries) ---------- */ +const QUAKE_ZONES = [[35.5,141.9],[38.3,142.4],[-6,130],[-20,-70],[-33,-72],[13,-90],[61,-148],[52,-170],[-41,174],[36,71],[28,84],[38,26],[-17,168],[-5,152],[18,-68],[40,30],[37,-121],[-1,-77],[14,121],[24,123]]; +const QUAKES = []; +for (let i = 0; i < 46; i++) { + const z = R.pick(QUAKE_ZONES); + QUAKES.push({ lat: z[0] + R.gauss() * 1.6, lon: z[1] + R.gauss() * 1.6, mag: +(2.6 + Math.pow(R(), 2.6) * 3.2).toFixed(1), t: R.range(-72, 0) * 3600, depth: Math.round(R.range(8, 120)) }); +} +const BIGQ = { lat: 35.62, lon: 141.95, mag: 6.4, t: -5.4 * 3600, depth: 32, place: 'off the coast of Honshu' }; +QUAKES.push(BIGQ); +for (let i = 0; i < 9; i++) QUAKES.push({ lat: BIGQ.lat + R.gauss() * 0.35, lon: BIGQ.lon + R.gauss() * 0.4, mag: +(3.4 + R() * 1.6).toFixed(1), t: BIGQ.t + R.range(0.2, 5) * 3600, depth: Math.round(R.range(10, 50)) }); + +/* ---------- fires (FIRMS-like hotspots; start times so the ribbon shows spread) ---------- */ +const FIRE_CLUSTERS = [ + [40.2,-8.1,'Central Portugal',90,1.0],[42.4,-7.4,'Galicia',60,0.9],[38.1,23.4,'Attica',50,0.7],[39.8,21.0,'Epirus',30,0.5],[36.8,30.6,'Antalya',40,0.6], + [34.4,-118.5,'Los Angeles County',45,0.8],[39.7,-121.6,'Butte County',40,0.6],[-14.0,-60.0,'Rondônia',180,1.6],[-9.5,24.0,'Katanga savanna',260,2.6], + [8.5,19.0,'Central African savanna',220,2.6],[-17.0,131.0,'Northern Territory',140,2.0],[61.5,128.0,'Yakutia',60,1.3],[-2.5,113.5,'Kalimantan',70,1.0], + [21.0,98.0,'Shan State',60,1.2],[-22.0,-55.0,'Mato Grosso do Sul',70,1.2], +]; +const FIRES = []; +for (const [la, lo, name, n, s] of FIRE_CLUSTERS) { + const tStart = R.range(-70, -6) * 3600; + for (let i = 0; i < n; i++) { + const rad = Math.sqrt(R()) * s, ang = R() * TAU; + // fronts move with prevailing wind: later ignitions sit downwind + const tt = tStart + (rad / s) * R.range(20, 110) * 3600; + FIRES.push({ lat: la + Math.sin(ang) * rad * 0.8, lon: lo + Math.cos(ang) * rad, t: tt, frp: R.range(5, 120), cluster: name }); + } +} + +/* ---------- tropical cyclone (illustrative name) ---------- */ +const CYCLONE = { + name: 'Typhoon Halcyon', designation: '26W (illustrative)', + // [hoursFromNow, lat, lon, windKmh, pressure hPa] + track: [[-72,11.2,141.8,95,992],[-60,11.9,139.6,110,985],[-48,12.6,137.4,130,975],[-36,13.4,135.3,150,965],[-24,14.3,133.1,170,955],[-12,15.2,130.9,180,948],[0,16.2,128.8,185,944], + [12,17.3,126.9,195,938],[24,18.6,125.1,200,935],[36,20.1,123.6,190,940],[48,21.8,122.5,175,950],[72,25.0,121.9,140,968],[96,28.6,124.4,110,980],[120,32.4,129.0,85,990]], + at(hours) { + const tr = this.track; if (hours <= tr[0][0]) return { lat: tr[0][1], lon: tr[0][2], wind: tr[0][3], p: tr[0][4] }; + for (let i = 1; i < tr.length; i++) if (hours <= tr[i][0]) { + const a = tr[i - 1], b = tr[i], f = (hours - a[0]) / (b[0] - a[0]); + return { lat: lerp(a[1], b[1], f), lon: lerp(a[2], b[2], f), wind: lerp(a[3], b[3], f), p: lerp(a[4], b[4], f) }; + } + const z = tr[tr.length - 1]; return { lat: z[1], lon: z[2], wind: z[3], p: z[4] }; + }, + cone(hours) { return hours <= 0 ? 0 : 35 + hours * 3.1; }, // km radius of uncertainty +}; + +/* ---------- cities (search + scatter) ---------- */ +const CITIES = [ + ['New York',40.7128,-74.006],['London',51.5072,-0.1276],['Tokyo',35.6762,139.6503],['Paris',48.8566,2.3522],['Shanghai',31.2304,121.4737],['Mumbai',19.076,72.8777], + ['São Paulo',-23.5505,-46.6333],['Mexico City',19.4326,-99.1332],['Cairo',30.0444,31.2357],['Lagos',6.5244,3.3792],['Istanbul',41.0082,28.9784],['Moscow',55.7558,37.6173], + ['Seoul',37.5665,126.978],['Jakarta',-6.2088,106.8456],['Delhi',28.6139,77.209],['Los Angeles',34.0522,-118.2437],['Chicago',41.8781,-87.6298],['Singapore',1.3521,103.8198], + ['Sydney',-33.8688,151.2093],['Dubai',25.2048,55.2708],['Hong Kong',22.3193,114.1694],['Berlin',52.52,13.405],['Madrid',40.4168,-3.7038],['Rome',41.9028,12.4964], + ['Bangkok',13.7563,100.5018],['Toronto',43.6532,-79.3832],['San Francisco',37.7749,-122.4194],['Buenos Aires',-34.6037,-58.3816],['Johannesburg',-26.2041,28.0473],['Nairobi',-1.2921,36.8219], + ['Kathmandu',27.7172,85.324],['Lisbon',38.7223,-9.1393],['Athens',37.9838,23.7275],['Reykjavík',64.1466,-21.9426],['Tromsø',69.6492,18.9553],['Honolulu',21.3069,-157.8583], + ['Manila',14.5995,120.9842],['Taipei',25.033,121.5654],['Rotterdam',51.9244,4.4777],['Suez',29.9668,32.5498],['Cape Canaveral',28.3922,-80.6077],['Dothan',31.2232,-85.3905], +]; +const CITY_V = CITIES.map(([n, la, lo]) => ({ name: n, lat: la, lon: lo })); + +/* ---------- people (fictional handles) ---------- */ +const PEOPLE = { + you: { h: 'you', name: 'You', hue: 205 }, + kora: { h: 'kora_maps', name: 'Kora M.', hue: 160 }, + tamang: { h: 'tamang_lens', name: 'Pemba T.', hue: 30 }, + rotor: { h: 'rotorwash_ret', name: 'J. Alder', hue: 95 }, + hydro: { h: 'hydro_nerd', name: 'Sita R.', hue: 200 }, + ais: { h: 'ais_owl', name: 'Marek', hue: 270 }, + sky: { h: 'skywatch_ana', name: 'Ana P.', hue: 330 }, + geo: { h: 'geodesy_dan', name: 'Dan K.', hue: 50 }, + crowd: { h: 'row_m_seat14', name: 'Lena', hue: 300 }, + news: { h: 'chopper_cam9', name: 'Desk 9', hue: 10 }, + ai: { h: 'claude', name: 'AI (proposal)', hue: 260, ai: true }, +}; + +Object.assign(GE, { AP, apByIata, FLIGHTS, flightAt, OPNAME, LANES, LANE_V, SHIPS, shipAt, SATS, ISS, CSS, satAt, QUAKES, BIGQ, FIRES, FIRE_CLUSTERS, CYCLONE, CITY_V, PEOPLE }); diff --git a/mobile/vision/src/js/20-gl.js b/mobile/vision/src/js/20-gl.js new file mode 100644 index 000000000..c4007b35a --- /dev/null +++ b/mobile/vision/src/js/20-gl.js @@ -0,0 +1,870 @@ +/* Hand-written WebGL2 renderer: ray-traced planet + single-scatter atmosphere, sprites, trails, + lines, meshes (city/terrain/water) and a mobile post chain (half-res dual-Kawase bloom, FXAA-lite, + ACES, grade, vignette, grain) with lens modes (cinematic / clear / night / thermal / NVG). */ +const GL = (GE.GL = {}); + +const GLSL_COMMON = ` +const float PI = 3.14159265; +float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); } +vec2 hash22(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*vec3(.1031,.1030,.0973)); p3 += dot(p3,p3.yzx+33.33); return fract((p3.xx+p3.yz)*p3.zy); } +float hash13(vec3 p3){ p3 = fract(p3*.1031); p3 += dot(p3,p3.zyx+31.32); return fract((p3.x+p3.y)*p3.z); } +float vnoise(vec2 p){ vec2 i=floor(p), f=fract(p); vec2 u=f*f*(3.-2.*f); + return mix(mix(hash12(i),hash12(i+vec2(1,0)),u.x), mix(hash12(i+vec2(0,1)),hash12(i+vec2(1,1)),u.x), u.y); } +float fbm(vec2 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise(p); p=p*2.03+vec2(1.7,9.2); a*=.5;} return s; } +float vnoise3(vec3 p){ vec3 i=floor(p), f=fract(p); vec3 u=f*f*(3.-2.*f); + float a=hash13(i), b=hash13(i+vec3(1,0,0)), c=hash13(i+vec3(0,1,0)), d=hash13(i+vec3(1,1,0)); + float e=hash13(i+vec3(0,0,1)), g=hash13(i+vec3(1,0,1)), h=hash13(i+vec3(0,1,1)), k=hash13(i+vec3(1,1,1)); + return mix(mix(mix(a,b,u.x),mix(c,d,u.x),u.y), mix(mix(e,g,u.x),mix(h,k,u.x),u.y), u.z); } +float fbm3(vec3 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise3(p); p=p*2.02+vec3(3.1,1.7,5.3); a*=.5;} return s; } +`; + +const GLSL_ATMO = ` +const float HR = 0.0021, HM = 0.0006, RA = 1.0165; +const vec3 BR = vec3(22.1, 51.5, 126.3); +const vec3 BM = vec3(16.0); +uniform vec3 uSun; +uniform float uSunI; +vec2 raySphere(vec3 ro, vec3 rd, float r){ float b=dot(ro,rd); float c=dot(ro,ro)-r*r; float d=b*b-c; if(d<0.) return vec2(1e9,-1e9); d=sqrt(d); return vec2(-b-d,-b+d); } +// optical depth (length units) of exp(-(r-1)/H) from radius r along zenith-cosine mu to infinity +float odH(float r, float mu, float H){ + float c = sqrt(PI*0.5*r/H); float h=(r-1.)/H; + if (mu >= 0.) return H*exp(-h)*c/(c*mu+1.); + float r0 = r*sqrt(max(0.,1.-mu*mu)); + if (r0 < 1.0) return 1e4; + float h0 = (r0-1.)/H; float c0 = sqrt(PI*0.5*r0/H); + return H*(2.*exp(-h0)*c0 - exp(-h)*c/(-c*mu+1.)); +} +vec3 sunTrans(vec3 x){ float r=length(x); float mu=dot(x,uSun)/r; return exp(-(BR*odH(r,mu,HR)+BM*1.11*odH(r,mu,HM))); } +vec3 atmo(vec3 ro, vec3 rd, float tEnd, float inside, int steps, out vec3 T){ + vec2 ta = raySphere(ro, rd, RA); + float t0 = max(ta.x, 0.), t1 = min(ta.y, tEnd); + T = vec3(1.); + if (t1 <= t0) return vec3(0.); + float mu = dot(rd, uSun); + float pR = 3./(16.*PI)*(1.+mu*mu); + const float g = 0.76; + float pM = 3./(8.*PI)*((1.-g*g)*(1.+mu*mu))/((2.+g*g)*pow(max(1.+g*g-2.*g*mu,1e-4),1.5)); + vec3 sR=vec3(0.), sM=vec3(0.); float oR=0., oM=0.; float L=t1-t0; + float N = float(steps); + for (int i=0;i<24;i++){ + if (i>=steps) break; + float fa = float(i)/N, fb = float(i+1)/N; + float ta0 = mix(fa, fa*fa, inside), tb0 = mix(fb, fb*fb, inside); + float ds = (tb0-ta0)*L; float t = t0 + (ta0+tb0)*0.5*L; + vec3 x = ro + rd*t; float r = length(x); float h=r-1.; + float dR = exp(-h/HR), dM = exp(-h/HM); + oR += dR*ds; oM += dM*ds; + float muS = dot(x,uSun)/r; + vec3 tau = BR*(oR+odH(r,muS,HR)) + BM*1.11*(oM+odH(r,muS,HM)); + vec3 att = exp(-tau); + sR += dR*att*ds; sM += dM*att*ds; + } + T = exp(-(BR*oR + BM*1.11*oM)); + return uSunI*(sR*BR*pR + sM*BM*pM); +} +`; + +const SRC = {}; +SRC.fsq_vs = `#version 300 es +out vec2 vUv; out vec2 vNdc; +void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2); vUv=p; vNdc=p*2.-1.; gl_Position=vec4(p*2.-1.,0.,1.); }`; + +SRC.globe_fs = `#version 300 es +precision highp float; +in vec2 vNdc; out vec4 frag; +${GLSL_COMMON} +${GLSL_ATMO} +uniform vec3 uCamR, uCamU, uCamB; uniform vec4 uTanS; uniform vec3 uEye; uniform float uEyeC; uniform float uT; uniform float uGmst; +uniform sampler2D uDay, uNight, uClouds, uWater, uTopo; +uniform vec4 uLens; // x mode, y night force, z cloud amount, w graticule +uniform vec4 uCyc; // xyz center, w radius (rad) +uniform vec4 uCycP; // x intensity, y spin, z eye radius +uniform float uAurora; +uniform mat3 uEnu; uniform vec3 uEyeLocal; uniform vec4 uCity; // x detail, y block m, z rot, w urban boost +uniform float uRange; uniform int uSteps; uniform float uInside; +uniform vec4 uFocus; // xyz focus dir, w strength (dim surroundings) +uniform vec4 uLocalWater; // xy normal, z offset (m), w enabled +uniform float uTraffic; +vec2 uvOf(vec3 n){ return vec2(atan(n.y,n.x)/(2.*PI)+0.5, 0.5-asin(clamp(n.z,-1.,1.))/PI); } +vec4 tex2(sampler2D s, vec2 uv){ + vec2 u2 = vec2(fract(uv.x+0.5)-0.5, uv.y); + vec2 dx = dFdx(uv), dy = dFdy(uv), dx2 = dFdx(u2), dy2 = dFdy(u2); + if (abs(dx2.x)+abs(dy2.x) < abs(dx.x)+abs(dy.x)) { dx = dx2; dy = dy2; } + return textureGrad(s, uv, dx, dy); +} +vec3 stars(vec3 d){ + float c = cos(uGmst), s = sin(uGmst); + d = vec3(c*d.x - s*d.y, s*d.x + c*d.y, d.z); + vec3 col = vec3(0.); + for (int L=0; L<3; L++){ + float sc = 60. + float(L)*70.; + vec3 a = abs(d); vec2 uv; float face; + if (a.x>a.y && a.x>a.z){ uv=d.yz/a.x; face=sign(d.x); } + else if (a.y>a.z){ uv=d.xz/a.y; face=2.+sign(d.y); } + else { uv=d.xy/a.z; face=4.+sign(d.z); } + vec2 g = uv*sc; vec2 id = floor(g); vec2 f = fract(g)-0.5; + vec2 rnd = hash22(id+face*17.+float(L)*31.); + float b = pow(rnd.x, 70.) * (L==0 ? 1.4 : 0.8); + vec2 off = (hash22(id*1.7+face)-0.5)*0.7; + float dd = length(f-off); + float w = fwidth(g.x)*0.9+0.02; + col += b * smoothstep(w*1.25, 0., dd) * mix(vec3(1.,.84,.7), vec3(.75,.85,1.), rnd.y) * 4.; + } + // milky way band (galactic north pole RA 192.86°, Dec 27.13°) + vec3 gnp = vec3(-0.8676, -0.1981, 0.4560); + float gb = dot(d, gnp); + float band = exp(-gb*gb*14.); + float n = fbm3(d*6.)*0.7 + fbm3(d*17.)*0.3; + col += band * smoothstep(0.35, 0.85, n) * vec3(0.10, 0.11, 0.15) * 0.9; + col += band * vec3(0.012,0.013,0.02); + return col; +} +float cycloneDensity(vec3 n){ + if (uCycP.x <= 0.) return 0.; + float cd = dot(n, uCyc.xyz); if (cd < 0.985) return 0.; + float d = acos(clamp(cd,-1.,1.)); + vec3 e1 = normalize(cross(vec3(0,0,1), uCyc.xyz)); vec3 e2 = cross(uCyc.xyz, e1); + vec3 v = n - uCyc.xyz*cd; float th = atan(dot(v,e2), dot(v,e1)); + float x = d/uCyc.w; + float sw = 2.*th + 5.2*log(max(x,0.03)) - uCycP.y; + float arms = pow(0.5 + 0.5*sin(sw), 2.4); + float tex = fbm(vec2(cos(th), sin(th))*x*9. + vec2(3.*log(x+0.05) - uCycP.y*0.2, uCycP.y*0.05)); + float dens = smoothstep(1.25, 0.28, x) * (0.12 + 0.88*arms) * (0.35 + 0.9*tex); + float cdo = smoothstep(0.26, 0.08, x) * (0.75 + 0.25*tex); + dens = max(dens, cdo); + float eye = smoothstep(uCycP.z*0.8, uCycP.z*1.6, d) * (1. + 0.25*smoothstep(uCycP.z*2.6, uCycP.z*1.6, d)); + return clamp(dens*1.15, 0., 1.) * eye * uCycP.x; +} +vec3 aurora(vec3 n, float nightF){ + if (uAurora <= 0.) return vec3(0.); + vec3 mp = vec3(0.0484, -0.1559, 0.9866); + float s = dot(n, mp); float hemi = sign(s); + float ml = asin(abs(s)) * 57.2958; + float band = 66.5 - uAurora*5.5; + float lonm = atan(dot(n, normalize(cross(mp, vec3(0,0,1)))), dot(n, normalize(cross(mp, cross(mp, vec3(0,0,1)))))); + float curt = 0.45 + 0.55*fbm(vec2(lonm*9. + uT*0.0006, uT*0.00025)); + float g = exp(-pow((ml-band)/(2.2+uAurora*0.6), 2.)) * curt; + float r = exp(-pow((ml-band-3.5)/3.5, 2.)) * 0.25; + return (vec3(0.12, 1.0, 0.5)*g + vec3(0.75, 0.15, 0.45)*r*0.45) * nightF * uAurora * 1.15; +} +vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3 emis){ + emis = vec3(0.); + float rot = uCity.z; mat2 m = mat2(cos(rot), -sin(rot), sin(rot), cos(rot)); + vec2 p = m*q; float B = uCity.y; + vec2 g = p/B; vec2 id = floor(g); vec2 f = fract(g); + float big = step(0.8, hash12(floor(id/5.)+3.)); + float rw = 0.085 + 0.05*step(4.5, mod(id.x,5.)) ; + vec2 e = min(f, 1.-f); + float road = 1. - smoothstep(rw*0.75, rw, min(e.x, e.y)); + float aa = clamp(fw/B*4., 0., 1.); + road = mix(road, 0.18, aa); + float h = hash12(id); + vec3 roof = mix(vec3(0.20,0.20,0.21), vec3(0.42,0.38,0.33), h); + roof = mix(roof, vec3(0.30,0.36,0.26), step(0.86, h)); // parks + float lot = vnoise(p*0.11)*0.12; + vec3 urb = mix(roof*(0.9+lot), vec3(0.075,0.078,0.085), road); + // rural: patchwork fields & forest + vec2 fid = floor(p/(B*2.6)); float fh = hash12(fid+7.); + vec3 field = base * mix(vec3(0.9,1.05,0.8), vec3(1.25,1.1,0.75), fh) * (0.85+0.3*vnoise(p*0.02)); + float forest = smoothstep(0.45,0.62, fbm(p*0.0016+3.)); + field = mix(field, base*vec3(0.55,0.75,0.5), forest); + vec3 col = mix(field, urb, urban); + // street lights at night along roads + float lamps = road * urban * (0.5+0.5*hash12(floor(p/9.))); + emis = vec3(1.0,0.58,0.24)*lamps*night*1.1*(1.-aa*0.55); + if (uTraffic > 0.5) { + vec2 rp = p/B; float along = abs(f.x-0.5) < abs(f.y-0.5) ? rp.y : rp.x; + float cars = smoothstep(0.75, 1., sin(along*38. - uT*2.5 + h*20.)) * road * urban * (1.-aa); + emis += mix(vec3(1.,0.15,0.1), vec3(1.,0.95,0.8), step(0.5, fract(along*3.)))*cars*(0.25 + night*1.4); + } + float lit = step(0.72, hash12(id*3.1)) * (1.-road) * urban * night; + emis += vec3(0.9,0.8,0.6)*lit*0.035; + return col; +} +void main(){ + vec3 rd = normalize(uCamR*(vNdc.x - uTanS.z)*uTanS.x + uCamU*(vNdc.y - uTanS.w)*uTanS.y - uCamB); + vec3 ro = uEye; + float b = dot(ro, rd); + float disc = b*b - uEyeC; + int mode = int(uLens.x+0.5); + float clear = mode==1 ? 1. : 0.; + vec3 col; float heat = 0.; + vec3 T; vec3 ins; + float nightForce = uLens.y; + if (disc > 0. && b < 0.) { + float t = uEyeC / (-b + sqrt(disc)); + vec3 p = ro + rd*t; vec3 n = normalize(p); + vec2 uv = uvOf(n); + vec3 day = pow(tex2(uDay, uv).rgb, vec3(2.2)); + vec3 nightTex = tex2(uNight, uv).rgb; + float water = tex2(uWater, uv).r; + float muS = dot(n, uSun); + // relief from topography + vec2 tx = vec2(1./2048., 1./1024.); + float h0 = texture(uTopo, uv).r; + float hx = texture(uTopo, uv+vec2(tx.x,0.)).r - texture(uTopo, uv-vec2(tx.x,0.)).r; + float hy = texture(uTopo, uv-vec2(0.,tx.y)).r - texture(uTopo, uv+vec2(0.,tx.y)).r; + vec3 east = normalize(vec3(-n.y, n.x, 0.)+1e-6); vec3 north = cross(n, east); + float bumpK = 2.2 * (1.-water); + vec3 nb = normalize(n - (east*hx + north*hy)*bumpK/ max(cos(asin(n.z)),0.2) * 0.6); + float lights = max(0., (nightTex.g*1.1 - nightTex.b*0.55)); + lights = pow(lights, 1.7) * 2.6; + float urban = smoothstep(0.03, 0.25, lights) + uCity.w; + // low-altitude synthetic ground detail + vec3 emisD = vec3(0.); + float nightF = 1. - smoothstep(-0.16, 0.06, muS); + nightF = max(nightF, nightForce); + if (uCity.x > 0.001) { + vec3 rdl = transpose(uEnu) * rd; vec2 q = (uEyeLocal + rdl * (t*6371000.)).xy; + if (uLocalWater.w > 0.) { float lw = smoothstep(uLocalWater.z-25., uLocalWater.z+25., dot(q, uLocalWater.xy)); water = max(water, lw*uCity.x); day = mix(day, vec3(0.012,0.03,0.045), lw*uCity.x); } + float fw = length(fwidth(q)); + vec3 dcol = cityDetail(day, q, clamp(urban,0.,1.)*(1.-water), nightF, fw, emisD); + float fade = uCity.x * (1.-smoothstep(150., 900., fw)) * (1.-water*0.9); + day = mix(day, dcol, fade); emisD *= fade; + } + // clouds (+ cyclone) + vec2 cuv = uv + vec2(uT*1.2e-7, 0.); + vec3 wp = n*5. + vec3(uT*2e-6); + cuv += (vec2(fbm3(wp), fbm3(wp+7.3))-0.5)*0.012; + float cl = tex2(uClouds, cuv).r; + float dK = smoothstep(1.2, 0.08, uRange); + float base0 = smoothstep(0.1, 0.9, cl); + if (dK > 0.) { + vec3 q3 = n*420. + vec3(uT*1.5e-5, 0., uT*1e-5); + float n1 = fbm3(q3), n2 = fbm3(q3*4.1 + 7.); + float cells = smoothstep(0.62 - cl*0.55, 0.92 - cl*0.35, n1*0.62 + n2*0.38 + cl*0.18); + cl = mix(base0, cells * smoothstep(0.03, 0.3, cl), dK); + } else cl = base0; + cl *= 0.94; + cl = max(cl, cycloneDensity(n)); + float cloudFade = smoothstep(0.0012, 0.006, uRange); + cl *= uLens.z * cloudFade * (1.-clear); + vec3 st = sunTrans(p*1.0005); + float shade = mix(1.0, 1.0-0.55*tex2(uClouds, cuv - vec2(uSun.y, -uSun.z)*0.0015).r*uLens.z*cloudFade, step(0.,muS)); + float diff = max(dot(nb, uSun), 0.); + vec3 skyAmb = vec3(0.022,0.036,0.07)*smoothstep(-0.25,0.5,muS); + vec3 irr = uSunI*(st*diff*shade + skyAmb); + if (clear > 0.5) irr = vec3(uSunI*0.32); + irr *= (1.-nightForce*0.97); + irr += vec3(0.05,0.07,0.13)*nightF*(1.-clear); + vec3 alb = day; + vec3 surf = alb * irr / PI; + // ocean specular + if (water > 0.01) { + vec3 v = -rd; + vec3 nw = n; + if (uRange < 0.05) { vec2 wq = uv*vec2(4000.,2000.)+uT*0.02; nw = normalize(n + (east*(vnoise(wq)-.5) + north*(vnoise(wq.yx+3.)-.5))*0.04); } + vec3 hv = normalize(v + uSun); + float nh = max(dot(nw, hv), 0.); + float rough = 0.18; + float a2 = rough*rough; float dd = nh*nh*(a2-1.)+1.; float D = a2/(PI*dd*dd); + float F = 0.02 + 0.98*pow(1.-max(dot(v,hv),0.), 5.); + vec3 spec = uSunI * st * D * F * max(dot(nw,uSun),0.) * 0.25; + float fres = 0.02 + 0.98*pow(1.-max(dot(nw,v),0.), 5.); + vec3 skyR = vec3(0.12,0.22,0.45)*uSunI*0.05*smoothstep(-0.2,0.4,muS); + surf += (spec*(1.-clear) + skyR*fres) * water * (1.-cl); + } + // night lights + moonlit black-marble land + vec3 lightCol = mix(vec3(1.0,0.66,0.32), vec3(1.0,0.86,0.64), smoothstep(0.4,1.4,lights)); + vec3 emis = lightCol * lights * 3.2 * nightF * (1. - uCity.x*0.94); + emis += pow(nightTex, vec3(2.2)) * vec3(0.5,0.6,1.0) * 0.12 * nightF; + emis += emisD; + emis *= (1.-cl*0.82); + // clouds lit + vec3 cirr = uSunI*(st*(0.25+0.75*max(dot(n,uSun)*0.9+0.1,0.)) + skyAmb*2.); + if (clear > 0.5) cirr = vec3(uSunI*0.3); + vec3 ccol = vec3(0.78) * cirr/PI + vec3(0.008,0.01,0.018)*nightF + lightCol*lights*0.25*nightF; + surf = mix(surf, ccol, cl); + surf += emis; + surf += aurora(n, nightF); + // graticule (clear lens) + if (uLens.w > 0.) { + vec2 ll = vec2(atan(n.y,n.x), asin(n.z))*57.2958/15.; + vec2 gg = abs(fract(ll+0.5)-0.5)/fwidth(ll); + float lineG = 1.-min(min(gg.x,gg.y),1.); + surf += vec3(0.35,0.5,0.7)*lineG*0.35*uLens.w; + } + // focus: gently dim surroundings of a situation + if (uFocus.w > 0.) { float fd = acos(clamp(dot(n, uFocus.xyz),-1.,1.)); surf *= mix(1., 0.55, uFocus.w*smoothstep(0.08, 0.5, fd)); } + ins = atmo(ro, rd, t, uInside, uSteps, T); + if (clear > 0.5) { ins *= 0.35; T = mix(T, vec3(1.), 0.6); } + col = surf*T + ins; + // heat: land warm by day, cities warm, ocean cool, clouds cold + heat = mix(0.32 + 0.38*diff*(1.-nightForce), 0.2 - 0.12*abs(n.z), water) + 0.25*clamp(lights,0.,1.); + heat = mix(heat, 0.06, cl); + } else { + // sky / space + ins = atmo(ro, rd, 1e9, uInside, uSteps, T); + vec3 sp = stars(rd) * (1.-clear*0.6); + float sd = dot(rd, uSun); + vec3 sun = vec3(1.,0.96,0.9) * (smoothstep(0.99996, 0.99999, sd)*400. + pow(max(sd,0.),900.)*3. + pow(max(sd,0.),40.)*0.08) * (1.-clear); + col = (sp + sun)*T + ins; + // aurora curtains at the limb + float tc = -b; vec3 cp = ro + rd*max(tc,0.); float hr = length(cp); + if (uAurora > 0. && hr > 1.004 && hr < 1.05) { + vec3 cn = normalize(cp); float nf = 1.-smoothstep(-0.2,0.0,dot(cn,uSun)); + col += aurora(cn, nf) * smoothstep(1.05,1.012,hr) * smoothstep(1.004,1.012,hr) * 0.3; + } + heat = 0.; + } + frag = vec4(col, heat); +}`; + +/* ---------- sprites ---------- */ +const SPRITE_VS_HEAD = `#version 300 es +precision highp float; +uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR; +uniform float uPx; uniform float uT; uniform float uAltX; uniform float uSizeK; uniform float uGmst; uniform vec2 uView; +uniform float uMinSize; uniform float uAlphaK; uniform vec3 uSun; +out vec4 vCol; out float vGlyph; out float vAng; out float vSize; out float vSeed; out float vHeat; +float occluded(vec3 p){ + vec3 E = uEyeH + uEyeL; vec3 d = p - E; float L = length(d); vec3 u = d/L; + float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c; + if (disc <= 0.) return 0.; + float tn = -b - sqrt(disc); + return (tn > 0. && tn < L) ? 1. : 0.; +} +vec4 project(vec3 p){ vec3 rel = (p - uEyeH) - uEyeL; vec4 c = uVP*vec4(rel,1.); if (c.z > c.w) c.z = c.w*0.99999; return c; } +float screenAngle(vec3 p, vec3 dir, vec4 c0){ + vec3 rel = (p - uEyeH) - uEyeL; float s = length(rel)*0.01 + 1e-7; + vec4 c1 = uVP*vec4(rel + dir*s, 1.); + vec2 a = c0.xy/c0.w, bb = c1.xy/c1.w; + return atan((bb.y-a.y)*uView.y, (bb.x-a.x)*uView.x); +} +vec3 headingDir(vec3 p, float hdg){ vec3 up = normalize(p); vec3 e = normalize(vec3(-up.y, up.x, 0.)+1e-7); vec3 nn = cross(up, e); return nn*cos(hdg) + e*sin(hdg); } +`; +SRC.sprite_vs = SPRITE_VS_HEAD + ` +in vec3 aPos; in vec4 aCol; in vec4 aPar; // size, glyph, heading, t0 (seconds; visible after) +in vec4 aExt; // x: tEnd, y: grow, z: seed, w: heat +void main(){ + vec3 p = aPos; + float vis = step(aPar.w, uT) * step(uT, aExt.x); + vec4 c = project(p); + float occ = occluded(p); + gl_Position = c; + float age = uT - aPar.w; + float grow = 1. + aExt.y * clamp(age/86400., 0., 2.); + float sz = max(aPar.x * uSizeK * grow, uMinSize); + gl_PointSize = vis * (1.-occ) * sz * uPx; + vCol = aCol; vCol.a *= uAlphaK; vGlyph = aPar.y; vSize = sz*uPx; vSeed = aExt.z + age*0.0; + vAng = aPar.z > -9. ? screenAngle(p, headingDir(p, aPar.z), c) : 0.; + vHeat = aExt.w; + if (aPar.y > 1.5 && aPar.y < 2.5) vSeed = age; // quake ring: pass age +}`; +SRC.flight_vs = SPRITE_VS_HEAD + ` +in vec3 aA; in vec3 aB; in vec4 aTime; in vec4 aMeta; // t0,dur,period,_ ; altKm, kind, seed, dKm +uniform float uTrail; uniform float uTrailLen; in float aK; +uniform vec4 uKindMask; uniform float uSelId; uniform vec4 uKindSize; +out float vK; +vec3 flightPos(float t, out float vis, out vec3 dir){ + float ph = mod(t - aTime.x, aTime.z); + float hf = aTime.z*0.5; float leg = step(hf, ph); float lp = ph - leg*hf; + vis = step(lp, aTime.y) * step(0., lp); + float pr = clamp(lp/aTime.y, 0., 1.); + vec3 A = mix(aA, aB, leg), B = mix(aB, aA, leg); + float om = acos(clamp(dot(A,B), -1., 1.)); float so = max(sin(om), 1e-5); + vec3 s = (sin((1.-pr)*om)*A + sin(pr*om)*B)/so; + vec3 s2 = (sin((1.-pr-0.002)*om)*A + sin((pr+0.002)*om)*B)/so; + dir = normalize(s2 - s); + float climb = clamp(min(pr,1.-pr)*aMeta.w/160., 0., 1.); + float alt = aMeta.x*sqrt(climb); + return s*(1. + alt*uAltX/6371.); +} +void main(){ + int kind = int(aMeta.y+0.5); + float km = kind==0?uKindMask.x: kind==1?uKindMask.y: kind==2?uKindMask.z: uKindMask.w; + float vis; vec3 dir; + float t = uT - aK*uTrailLen; + vec3 p = flightPos(t, vis, dir); + vec4 c = project(p); + float occ = occluded(p); + gl_Position = c; + float sel = float(abs(float(gl_InstanceID) - uSelId) < 0.5); + float ks = kind==0?uKindSize.x: kind==1?uKindSize.y: kind==2?uKindSize.z: uKindSize.w; + float sz = max(ks*uSizeK*(1.+sel*0.8), uMinSize); + gl_PointSize = vis*(1.-occ)*km*sz*uPx; + vec3 colK = kind==0 ? vec3(0.42,0.74,1.0) : kind==1 ? vec3(1.0,0.56,0.3) : kind==2 ? vec3(0.55,1.0,0.5) : vec3(0.78,0.85,1.0); + colK = mix(colK, vec3(1.0,0.86,0.45), sel); + vCol = vec4(colK, vis*(1.-occ)*km*uAlphaK*(uTrail>0.5 ? pow(1.-aK,1.6)*(0.55+sel*0.45) : 1.)); + vK = aK; + vGlyph = 1.; vSize = sz*uPx; vSeed = aMeta.z; vHeat = 0.55; + vAng = screenAngle(p, dir, c); +}`; +SRC.sat_vs = SPRITE_VS_HEAD + ` +in vec4 aEl; in vec4 aSM; // inc, raan, u0, n ; altKm, kind, geo, seed +uniform vec4 uSatMask0; uniform vec4 uSatMask1; uniform float uSelId; +void main(){ + float u = aEl.z + aEl.w*uT; float r = 1. + aSM.x/6371.; + float cu=cos(u), su=sin(u), cO=cos(aEl.y), sO=sin(aEl.y), ci=cos(aEl.x), si=sin(aEl.x); + vec3 x = vec3(cu*cO - su*ci*sO, cu*sO + su*ci*cO, su*si); + vec3 p; + if (aSM.z > 0.5) p = vec3(cos(aEl.z), sin(aEl.z), 0.)*r; + else { float cg=cos(-uGmst), sg=sin(-uGmst); p = r*vec3(x.x*cg - x.y*sg, x.x*sg + x.y*cg, x.z); } + int k = int(aSM.y+0.5); + float m = k==0?uSatMask0.x: k==1?uSatMask0.y: k==2?uSatMask0.z: k==3?uSatMask0.w: k==4?uSatMask1.x: k==5?uSatMask1.y: uSatMask1.z; + vec4 c = project(p); float occ = occluded(p); + gl_Position = c; + float sel = float(abs(float(gl_VertexID) - uSelId) < 0.5); + float base = k==6 ? 15. : k==5 ? 4.0 : k==3 ? 2.6 : k==4 ? 1.4 : 1.6; + // sunlit satellites glint; in earth shadow they dim + vec3 sd = uSun; float bb = dot(p, sd); float rho2 = dot(p,p) - bb*bb; float lit = (bb < 0. && rho2 < 1.) ? 0.25 : 1.; + float sz = max(base*uSizeK*(1.+sel), uMinSize); + gl_PointSize = (1.-occ)*m*sz*uPx; + vec3 col = k==6 ? vec3(1.0,0.9,0.7) : k==5 ? vec3(0.95,0.9,1.0) : k==4 ? vec3(0.7,0.62,0.7) : k==3 ? vec3(0.85,0.7,1.0) : k==2 ? vec3(0.7,0.85,1.0) : vec3(0.79,0.66,1.0); + vCol = vec4(col*lit, m*(1.-occ)*uAlphaK*(k==6||k==5 ? 1. : k==3 ? 0.7 : 0.42)); + vGlyph = k==6 ? 7. : 0.; vSize = sz*uPx; vSeed = aSM.w; vHeat = 0.2; vAng = 0.; +}`; +SRC.sun_uniform = `uniform vec3 uSun;`; + +SRC.sprite_fs = `#version 300 es +precision highp float; +in vec4 vCol; in float vGlyph; in float vAng; in float vSize; in float vSeed; in float vHeat; +uniform float uT; uniform float uPass; uniform float uWall; uniform float uGlyphPx; +out vec4 frag; +${GLSL_COMMON} +float sdSeg(vec2 p, vec2 a, vec2 b){ vec2 pa=p-a, ba=b-a; float h=clamp(dot(pa,ba)/dot(ba,ba),0.,1.); return length(pa-ba*h); } +void main(){ + vec2 q = gl_PointCoord*2.-1.; q.y = -q.y; + float r = length(q); + int g = int(vGlyph+0.5); + float px = 2.5/max(vSize,1.); + float a = 0.; vec3 col = vCol.rgb; float glow = 0.; + if (vSize < uGlyphPx && g != 2 && g != 11 && g != 10 && g != 9 && g != 6 && g != 14 && g != 12) g = 0; + float ca = cos(vAng - 1.5708), sa = sin(vAng - 1.5708); + vec2 rq = vec2(ca*q.x + sa*q.y, -sa*q.x + ca*q.y); + if (g == 0) { a = exp(-r*r*7.)*1.0 + exp(-r*r*2.2)*0.18; } + else if (g == 1) { // aircraft + vec2 p = rq*1.15; + float fus = sdSeg(p, vec2(0.,-0.72), vec2(0.,0.78)) - 0.085; + float wing = max(abs(p.y + 0.02 + abs(p.x)*0.42) - 0.075, abs(p.x) - 0.86); + float tail = max(abs(p.y + 0.66 + abs(p.x)*0.3) - 0.05, abs(p.x) - 0.34); + float d = min(fus, min(wing, tail)); + a = (smoothstep(px, -px, d) + exp(-r*r*3.)*0.22)*0.85; + } + else if (g == 2) { // quake ring: vSeed = age (s) + float age = vSeed; float ph = fract(uT/2.4); + float rr = 0.25 + 0.7*ph; + float pulse = exp(-pow((r-rr)/0.05,2.)) * (1.-ph) * step(age, 86400.*1.5); + float base = exp(-pow((r-0.55)/0.035,2.))*0.55 + exp(-r*r*40.)*1.2; + a = (pulse + base) * smoothstep(1.,0.9,r); + } + else if (g == 3) { // fire + float fl = 0.7 + 0.3*sin(uT*7. + vSeed*30.) * sin(uT*3.1 + vSeed*11.); + a = (exp(-r*r*9.)*1.3 + exp(-r*r*2.5)*0.35) * fl; + col = mix(vec3(1.0,0.35,0.08), vec3(1.0,0.85,0.45), exp(-r*r*14.)); + } + else if (g == 4) { // ship hull + vec2 p = rq*1.2; + float d = max(abs(p.x) - 0.26 + max(p.y-0.35,0.)*0.55, abs(p.y) - 0.8); + a = smoothstep(px, -px, d) + exp(-r*r*4.)*0.15; + } + else if (g == 5) { // camera eye + float ring = abs(r - 0.62) - 0.1; + float lid = abs(length(q*vec2(1.,1.9)) - 0.86) - 0.07; + float d = min(ring, r - 0.26); + a = smoothstep(px, -px, min(d, lid)) * 0.95 + exp(-r*r*3.)*0.18; + } + else if (g == 6) { // signed capture / hologram seal + float dia = abs(q.x)+abs(q.y); + float d = min(abs(dia - 0.62) - 0.09, dia - 0.22); + float beam = exp(-q.x*q.x*80.) * smoothstep(-1., 0., -q.y) * 0.0; + a = smoothstep(px, -px, d) + exp(-r*r*3.)*0.35 + beam; + } + else if (g == 7) { // satellite / station + vec2 p = q*1.25; + float body = max(abs(p.x)-0.16, abs(p.y)-0.16); + float panel = max(abs(abs(p.x)-0.58)-0.3, abs(p.y)-0.13); + float truss = max(abs(p.x)-0.9, abs(p.y)-0.03); + a = smoothstep(px, -px, min(min(body,panel),truss)) + exp(-r*r*3.)*0.35; + } + else if (g == 8) { // helicopter + vec2 p = rq*1.2; + float rotor = abs(r*1.2 - 0.82) - 0.03; + float spin = abs(sin(atan(q.y,q.x)*2. + uT*40.)); + float blades = (r < 0.95) ? smoothstep(0.9, 1., spin) * 0.55 : 0.; + float body = length(p*vec2(1.7,1.)) - 0.32; + float boom = sdSeg(p, vec2(0.,-0.1), vec2(0.,-0.82)) - 0.05; + a = max(smoothstep(px, -px, min(body, boom)), blades) + smoothstep(px,-px,rotor)*0.35 + exp(-r*r*3.)*0.18; + } + else if (g == 9) { // situation pin + float ph = fract(uT*0.5 + vSeed); + a = smoothstep(px,-px, abs(r-0.42)-0.07) + exp(-r*r*30.)*1.4 + exp(-pow((r-0.4-ph*0.55)/0.06,2.))*(1.-ph)*0.8; + } + else if (g == 10) { // hex + vec2 p = abs(q); float d = max(p.x*0.866 + p.y*0.5, p.y) - 0.86; + a = smoothstep(px*2., -px*2., d) * 0.5 + smoothstep(px,-px, abs(d+0.02)-0.03)*0.6; + } + else if (g == 11) { // soft blob (smoke / air quality / radar) + float n = vnoise(q*2.5 + vSeed*13. + uT*0.02); + a = exp(-r*r*2.6) * (0.55+0.45*n); + } + else if (g == 12) { // lightning + float fl = step(0.93, fract(sin(floor(uT*3.+vSeed*40.)*91.7)*437.1)); + a = (exp(-r*r*18.)*2. + exp(-min(abs(q.x),abs(q.y))*30.)*exp(-r*2.)*0.8) * fl; + } + else if (g == 13) { // infra square + vec2 p = abs(q); float d = max(p.x,p.y) - 0.55; + a = smoothstep(px,-px, abs(d)-0.09) + smoothstep(px,-px,max(p.x,p.y)-0.18) + exp(-r*r*3.)*0.15; + } + else if (g == 14) { // volcano triangle + vec2 p = q; p.y += 0.2; + float d = max(abs(p.x)*0.866 + p.y*0.5, -p.y) - 0.45; + a = smoothstep(px,-px, abs(d)-0.08) + exp(-r*r*3.)*0.2; + } + else if (g == 15) { // wind streak (oriented) + a = exp(-(rq.x*rq.x*60. + rq.y*rq.y*1.6)) * smoothstep(1.,0.4,abs(rq.y)); + } + else if (g == 16) { // runner / small dot with ring + a = smoothstep(px,-px, r-0.42) + smoothstep(px,-px, abs(r-0.7)-0.06)*0.5; + } + else if (g == 17) { // drone (quad) + vec2 p = abs(rq); + float arms = sdSeg(p, vec2(0.), vec2(0.5,0.5)) - 0.06; + float rot = abs(length(p - vec2(0.55)) - 0.22) - 0.05; + a = smoothstep(px,-px, min(arms, rot)) + exp(-r*r*4.)*0.2; + } + if (r > 1.) a = 0.; + a *= vCol.a; + if (a < 0.003) discard; + if (uPass > 0.5) frag = vec4(col, a*0.85); // alpha-blended pass + else frag = vec4(col*a, vHeat*a); // additive pass +}`; + +/* ---------- lines (world space, polyline strips) ---------- */ +SRC.line_vs = `#version 300 es +precision highp float; +in vec3 aPos; in vec4 aCol; in vec2 aD; // dist along (units), dashed flag +uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR; uniform float uAlphaK; +out vec4 vCol; out vec2 vD; +void main(){ + vec3 E = uEyeH + uEyeL; vec3 d = aPos - E; float L = length(d); vec3 u = d/L; + float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c; float occ = 0.; + if (disc > 0.) { float tn = -b - sqrt(disc); occ = (tn > 0. && tn < L) ? 1. : 0.; } + vec4 cp = uVP*vec4((aPos-uEyeH)-uEyeL, 1.); + if (cp.z > cp.w) cp.z = cp.w*0.99999; + gl_Position = cp; + vCol = aCol; vCol.a *= (1.-occ*0.92)*uAlphaK; vD = aD; +}`; +SRC.line_fs = `#version 300 es +precision highp float; +in vec4 vCol; in vec2 vD; uniform float uDashK; uniform float uT; out vec4 frag; +void main(){ + float a = vCol.a; + if (vD.y > 0.5 && fract(vD.x*uDashK - uT*0.0) > 0.55) discard; + if (vD.y > 1.5) a *= 0.5 + 0.5*sin(vD.x*uDashK*6.2832 - uT*3.); + frag = vec4(vCol.rgb*a, a*0.3); +}`; + +/* ---------- meshes (local ENU meters, camera-relative matrix) ---------- */ +SRC.mesh_vs = `#version 300 es +precision highp float; +in vec3 aPos; in vec3 aNor; in vec2 aUV; in vec4 aAttr; // type, hash, height, misc +uniform mat4 uMVP; uniform float uT; uniform vec4 uFlood; // x: tHours, y: start h, z: speed m/h, w: crest m +out vec3 vPos; out vec3 vNor; out vec2 vUV; out vec4 vAttr; +void main(){ + vec3 p = aPos; + if (aAttr.x > 1.5 && aAttr.x < 2.5) { // flood water: rise with crest arrival + float s = aUV.x; float arr = uFlood.y + s/uFlood.z; + float dt = uFlood.x - arr; + float crest = uFlood.w * (dt > 0. ? exp(-dt/5.) * smoothstep(0., 0.4, dt) : exp(-dt*dt*6.)) ; + p.z += crest * 0.5; + } + vPos = p; vNor = aNor; vUV = aUV; vAttr = aAttr; + gl_Position = uMVP*vec4(p, 1.); +}`; +SRC.mesh_fs = `#version 300 es +precision highp float; +in vec3 vPos; in vec3 vNor; in vec2 vUV; in vec4 vAttr; +uniform vec3 uSunL; uniform vec3 uSunCol; uniform vec3 uSkyCol; uniform vec3 uFogCol; uniform vec3 uEyeL; +uniform float uNight; uniform float uT; uniform float uVis; uniform vec4 uFlood; uniform float uClear; +out vec4 frag; +${GLSL_COMMON} +void main(){ + int type = int(vAttr.x+0.5); + vec3 n = normalize(vNor); + vec3 v = normalize(uEyeL - vPos); + float diff = max(dot(n, uSunL), 0.); + vec3 base; vec3 emis = vec3(0.); float heat = 0.35; float spec = 0.; + if (type == 0) { // building + float h = vAttr.y; + base = mix(vec3(0.34,0.35,0.37), vec3(0.62,0.57,0.5), h); + base = mix(base, vec3(0.2,0.25,0.3), step(0.7, h)); // glass towers + if (abs(n.z) < 0.5) { + vec2 f = vec2(vUV.x/3.2, vUV.y/3.6); + vec2 id = floor(f); vec2 fr = fract(f); + float win = step(0.18, fr.x)*step(fr.x,0.82)*step(0.22,fr.y)*step(fr.y,0.78); + float r = hash12(id + h*91.); + float lit = step(1. - (0.12 + 0.5*uNight), r) * win * step(vUV.y, vAttr.z - 1.); + vec3 wc = mix(vec3(1.0,0.72,0.42), vec3(0.75,0.85,1.0), step(0.6, hash12(id*1.3+h))); + emis += wc * lit * (0.9 + 0.6*r) * uNight * 2.2; + base = mix(base, base*0.45 + uSkyCol*0.08, win*0.8); + spec = win*(1.-uNight)*0.6; + } else base *= 0.82; + float ao = smoothstep(0., 14., vUV.y + (1.-abs(n.z))*0.) * 0.35 + 0.65; + if (abs(n.z) < 0.5) base *= ao; + heat = 0.4 + 0.3*uNight; + } else if (type == 1) { // terrain + float hgt = vAttr.z; float slope = 1.-n.z; + vec3 forest = vec3(0.035,0.075,0.025), meadow = vec3(0.15,0.17,0.07), rock = vec3(0.22,0.2,0.18), snow = vec3(0.85,0.88,0.93); + float nz = vnoise(vPos.xy*0.004)*0.5 + vnoise(vPos.xy*0.02)*0.25 + vnoise(vPos.xy*0.09)*0.12; + base = mix(forest, meadow, smoothstep(3000., 3700., hgt + nz*500.)); + base = mix(base, forest*0.8, smoothstep(0.2, 0.45, slope) * (1. - smoothstep(3200., 3800., hgt))); + base = mix(base, rock, clamp(smoothstep(0.55, 0.85, slope + nz*0.25) + smoothstep(3900., 4500., hgt + nz*400.), 0., 1.)); + base = mix(base, snow, smoothstep(4700., 5100., hgt + nz*700. - slope*500.)); + // flood scar (mud) along the channel once the crest has passed + float scar = vAttr.w; // 0..1 near channel + float s = vUV.x; float arr = uFlood.y + s/uFlood.z; + float passed = smoothstep(0., 0.6, uFlood.x - arr); + base = mix(base, vec3(0.16,0.14,0.11), scar*passed*0.8); + heat = 0.3 + 0.2*diff; + } else if (type == 2) { // water (river / flood) + float s = vUV.x; float across = abs(vUV.y); + float arr = uFlood.y + s/uFlood.z; float dt = uFlood.x - arr; + float w = 0.18 + 0.82*(dt > 0. ? exp(-dt/6.)*smoothstep(0., 0.3, dt) : exp(-dt*dt*5.)); + if (across > w) discard; + float flow = vnoise(vec2(s*0.02 - uT*0.9, vUV.y*6.)); + base = mix(vec3(0.25,0.22,0.17), vec3(0.45,0.38,0.28), flow) * mix(1., 1.25, step(0.08, w-0.18)); + spec = 0.8; heat = 0.12; + emis += vec3(0.9,0.8,0.6)*smoothstep(0.85,1.,flow)*0.06*(1.-uNight); + } else if (type == 3) { // stadium seats + crowd + base = vec3(0.16,0.17,0.2); + float crowd = step(0.45, hash12(floor(vUV*vec2(1.6,1.2)))); + float flash = step(0.985, hash12(floor(vUV*vec2(1.6,1.2)) + floor(uT*2.))); + emis += mix(vec3(0.5,0.6,1.0), vec3(1.0,0.95,0.9), flash) * crowd * (0.05 + flash*4.) * uNight; + heat = 0.7; + } else if (type == 4) { // flat pads, runways, pitch + float h = vAttr.y; + base = h > 0.5 ? vec3(0.06,0.2,0.05)*(0.85+0.15*step(0.5,fract(vUV.x/10.))) : vec3(0.12,0.12,0.13); + if (h < 0.5) base += vec3(0.7)*step(0.97, fract(vUV.x/30.))*step(abs(vUV.y - vAttr.w*0.5), 0.6); + float edge = step(vUV.y, 1.6) + step(vAttr.w - 1.6, vUV.y); + float dots = step(0.93, fract(vUV.x/40.)); + emis += (h > 0.5 ? vec3(0.05,0.2,0.04)*0.8 : vec3(1.0,0.85,0.55)*dots*edge*2.5 + vec3(0.25,0.45,1.0)*step(0.96, fract((vUV.x+vUV.y)/23.))*0.6) * uNight; + heat = 0.45; + } else if (type == 5) { // stage / light rig / beacon + base = vec3(0.05); + emis = mix(vec3(0.9,0.3,1.0), vec3(0.3,0.8,1.0), 0.5+0.5*sin(uT*1.7+vPos.x*0.05)) * 3.0; + heat = 0.9; + } else { // houses + base = mix(vec3(0.55,0.45,0.38), vec3(0.75,0.72,0.66), vAttr.y); + if (n.z > 0.5) base = mix(vec3(0.45,0.2,0.15), vec3(0.3,0.35,0.42), step(0.5, vAttr.y)); + emis += vec3(1.0,0.7,0.4)*step(0.6, vAttr.y)*uNight*0.5*step(n.z,0.5); + } + vec3 amb = uSkyCol * (0.55 + 0.45*n.z); + vec3 col = base * (uSunCol*diff + amb); + if (spec > 0.) { vec3 hv = normalize(v + uSunL); col += uSunCol * pow(max(dot(n,hv),0.), 60.) * spec * 0.6; col += uSkyCol*spec*0.25*pow(1.-max(dot(n,v),0.),3.); } + col += emis; + float dist = length(uEyeL - vPos); + float fog = 1. - exp(-dist / uVis); + col = mix(col, uFogCol, clamp(fog, 0., 0.8)); + frag = vec4(col, heat); +}`; + +/* ---------- post ---------- */ +SRC.bright_fs = `#version 300 es +precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; uniform float uThr; out vec4 frag; +void main(){ + vec3 c = vec3(0.); + c += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb; + c += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb; + c *= 0.25; + float l = max(c.r, max(c.g, c.b)); + float k = clamp(l - uThr*0.6, 0., uThr*0.8); k = k*k/(4.*uThr*0.8+1e-4); + float w = max(k, l - uThr) / max(l, 1e-4); + frag = vec4(min(c*w, vec3(60.)), 1.); +}`; +SRC.down_fs = `#version 300 es +precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; out vec4 frag; +void main(){ + vec3 s = texture(uSrc, vUv).rgb*4.; + s += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb; + s += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb; + frag = vec4(s/8., 1.); +}`; +SRC.up_fs = `#version 300 es +precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform sampler2D uBase; uniform vec2 uTexel; uniform float uMix; out vec4 frag; +void main(){ + vec3 s = vec3(0.); vec2 o = uTexel; + s += texture(uSrc, vUv + vec2(-o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,o.y)).rgb*2.; + s += texture(uSrc, vUv + vec2(0.,o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(o.x,o.y)).rgb*2.; + s += texture(uSrc, vUv + vec2(o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(o.x,-o.y)).rgb*2.; + s += texture(uSrc, vUv + vec2(0.,-o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,-o.y)).rgb*2.; + frag = vec4(s/12. + texture(uBase, vUv).rgb*uMix, 1.); +}`; +SRC.comp_fs = `#version 300 es +precision highp float; in vec2 vUv; out vec4 frag; +uniform sampler2D uScene, uBloom; uniform vec2 uTexel; uniform float uExposure, uBloomK, uVig, uGrain, uT, uFade, uFxaa, uSat; +uniform int uMode; uniform vec3 uLift; uniform vec3 uGain; +${GLSL_COMMON} +vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14),0.,1.); } +float luma(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); } +vec3 ironbow(float t){ + t = clamp(t,0.,1.); + vec3 a = vec3(0.0,0.0,0.05), b = vec3(0.32,0.0,0.55), c = vec3(0.86,0.12,0.25), d = vec3(1.0,0.6,0.0), e = vec3(1.0,1.0,0.85); + if (t < 0.25) return mix(a,b,t/0.25); if (t < 0.5) return mix(b,c,(t-0.25)/0.25); if (t < 0.78) return mix(c,d,(t-0.5)/0.28); return mix(d,e,(t-0.78)/0.22); +} +vec4 sampleScene(vec2 uv){ + vec4 c = texture(uScene, uv); + if (uFxaa > 0.5) { + float lN = luma(texture(uScene, uv + vec2(0., uTexel.y)).rgb), lS = luma(texture(uScene, uv - vec2(0., uTexel.y)).rgb); + float lE = luma(texture(uScene, uv + vec2(uTexel.x, 0.)).rgb), lW = luma(texture(uScene, uv - vec2(uTexel.x, 0.)).rgb); + float lM = luma(c.rgb); + float mn = min(lM, min(min(lN,lS), min(lE,lW))), mx = max(lM, max(max(lN,lS), max(lE,lW))); + if (mx - mn > max(0.06, mx*0.15)) { + vec2 dir = vec2(-((lN)-(lS)), (lE)-(lW)); + float rcp = 1./(min(abs(dir.x),abs(dir.y)) + 1e-3*(lN+lS+lE+lW) + 1e-4); + dir = clamp(dir*rcp, -4., 4.) * uTexel; + vec4 a = 0.5*(texture(uScene, uv + dir*(1./3.-0.5)) + texture(uScene, uv + dir*(2./3.-0.5))); + c = vec4(a.rgb, c.a); + } + } + return c; +} +void main(){ + vec4 s = sampleScene(vUv); + vec3 b = texture(uBloom, vUv).rgb; + vec3 c = (s.rgb + b*uBloomK) * uExposure; + float heat = s.a; + vec2 cc = vUv - 0.5; + float vig = 1. - dot(cc, cc) * uVig; + if (uMode == 3) { // thermal + float hv = heat*0.85 + luma(c)*0.12 + luma(b)*0.05; + c = ironbow(hv) * 0.95; + } else if (uMode == 4) { // NVG + float l = luma(aces(c*2.2)); + l = pow(l, 0.8) + (hash12(vUv*vec2(1311.,977.) + fract(uT)*91.)-0.5)*0.12; + l *= 0.92 + 0.08*sin(vUv.y*900.); + c = vec3(0.25, 1.0, 0.35) * l * 1.1 + vec3(0.0,0.02,0.0); + float circ = smoothstep(0.62, 0.48, length(cc*vec2(1.,1.))); + c *= mix(0.15, 1., circ); + } else { + c = aces(c); + float l = luma(c); + c = mix(vec3(l), c, uSat); + c = c*uGain + uLift*(1.-c); + if (uMode == 2) c = mix(c, c*vec3(0.75,0.85,1.15), 0.5); + } + c *= vig; + c = pow(max(c, 0.), vec3(1./2.2)); + c += (hash12(vUv*vec2(1717.,1313.) + fract(uT*0.37)*77.) - 0.5) * uGrain; + c += (hash12(vUv*vec2(911.,733.)) - 0.5)/255.; + frag = vec4(c * uFade, 1.); +}`; +SRC.trail_fs = `#version 300 es +precision highp float; in vec4 vCol; in float vK; out vec4 frag; +void main(){ float a = vCol.a; if (a < 0.002) discard; frag = vec4(vCol.rgb*a, a*0.3); }`; +SRC.copy_fs = `#version 300 es +precision highp float; in vec2 vUv; uniform sampler2D uSrc; out vec4 frag; void main(){ frag = texture(uSrc, vUv); }`; + +/* ---------- GL helpers ---------- */ +GL.init = function (canvas) { + const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, preserveDrawingBuffer: false, powerPreference: 'high-performance', desynchronized: false }); + if (!gl) return null; + GL.gl = gl; + GL.hdr = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float'); + GL.aniso = gl.getExtension('EXT_texture_filter_anisotropic'); + gl.getExtension('OES_texture_float_linear'); + GL.vao0 = gl.createVertexArray(); + GL.progs = {}; + const P = (name, vs, fs) => (GL.progs[name] = GL.program(vs, fs, name)); + P('globe', SRC.fsq_vs, SRC.globe_fs); + P('sprite', SRC.sprite_vs, SRC.sprite_fs); + P('flight', SRC.flight_vs, SRC.sprite_fs); + P('sat', SRC.sat_vs, SRC.sprite_fs); + P('trail', SRC.flight_vs, SRC.trail_fs); + P('line', SRC.line_vs, SRC.line_fs); + P('mesh', SRC.mesh_vs, SRC.mesh_fs); + P('bright', SRC.fsq_vs, SRC.bright_fs); + P('down', SRC.fsq_vs, SRC.down_fs); + P('up', SRC.fsq_vs, SRC.up_fs); + P('comp', SRC.fsq_vs, SRC.comp_fs); + P('copy', SRC.fsq_vs, SRC.copy_fs); + return gl; +}; +GL.program = function (vsSrc, fsSrc, name) { + const gl = GL.gl; + const mk = (type, src) => { + const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s); + const lines = src.split('\n').map((l, i) => i + 1 + ': ' + l); + const m = /0:(\d+)/.exec(log || ''); const ln = m ? +m[1] : 0; + console.error(`[${name}] shader error`, log, '\n', lines.slice(Math.max(0, ln - 4), ln + 2).join('\n')); + throw new Error('shader compile failed: ' + name); + } + return s; + }; + const p = gl.createProgram(); + gl.attachShader(p, mk(gl.VERTEX_SHADER, vsSrc)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error(`[${name}] link`, gl.getProgramInfoLog(p)); throw new Error('link failed ' + name); } + const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < n; i++) { const inf = gl.getActiveUniform(p, i); u[inf.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(p, inf.name); } + const a = {}, na = gl.getProgramParameter(p, gl.ACTIVE_ATTRIBUTES); + for (let i = 0; i < na; i++) { const inf = gl.getActiveAttrib(p, i); a[inf.name] = gl.getAttribLocation(p, inf.name); } + return { p, u, a, name }; +}; +GL.texture = function (img, { mips = true, wrap = 'repeat', aniso = 8 } = {}) { + const gl = GL.gl, t = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, t); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap === 'repeat' ? gl.REPEAT : gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + if (mips) gl.generateMipmap(gl.TEXTURE_2D); + if (GL.aniso && aniso) gl.texParameterf(gl.TEXTURE_2D, GL.aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(aniso, gl.getParameter(GL.aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))); + return t; +}; +GL.target = function (w, h, { hdr = true, depth = false, linear = true } = {}) { + const gl = GL.gl; + w = Math.max(1, w | 0); h = Math.max(1, h | 0); + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + const useHdr = hdr && GL.hdr; + gl.texImage2D(gl.TEXTURE_2D, 0, useHdr ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, useHdr ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, linear ? gl.LINEAR : gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + let rb = null; + if (depth) { + rb = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, rb); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, w, h); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb); + } + const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + if (!ok && useHdr) { GL.hdr = false; return GL.target(w, h, { hdr: false, depth, linear }); } + return { tex, fb, rb, w, h, dispose() { gl.deleteTexture(tex); gl.deleteFramebuffer(fb); if (rb) gl.deleteRenderbuffer(rb); } }; +}; +// chain of render targets for a given output size +GL.chain = function (w, h) { + const s = GL.target(w, h, { depth: true }); + const lv = []; + let cw = Math.max(1, (w / 2) | 0), ch = Math.max(1, (h / 2) | 0); + for (let i = 0; i < 4; i++) { lv.push({ a: GL.target(cw, ch), b: null }); cw = Math.max(1, (cw / 2) | 0); ch = Math.max(1, (ch / 2) | 0); } + for (let i = 0; i < 3; i++) lv[i].b = GL.target(lv[i].a.w, lv[i].a.h); + return { w, h, scene: s, lv, dispose() { s.dispose(); lv.forEach((l) => { l.a.dispose(); l.b && l.b.dispose(); }); } }; +}; +GL.fsq = function (prog) { const gl = GL.gl; gl.useProgram(prog.p); gl.bindVertexArray(GL.vao0); gl.drawArrays(gl.TRIANGLES, 0, 3); }; +GL.bindTex = function (prog, name, tex, unit) { const gl = GL.gl; gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(prog.u[name], unit); }; +GL.buffer = function (data, usage) { const gl = GL.gl, b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, usage || gl.STATIC_DRAW); return b; }; +// build a VAO from {name: [buffer, size, stride, offset, divisor]} +GL.vao = function (prog, spec, index) { + const gl = GL.gl, vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + for (const k in spec) { + const loc = prog.a[k]; if (loc == null || loc < 0) continue; + const [buf, size, stride, off, div] = spec[k]; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, off * 4); + gl.vertexAttribDivisor(loc, div || 0); + } + if (index) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index); + gl.bindVertexArray(null); + return vao; +}; diff --git a/mobile/vision/src/js/30-world.js b/mobile/vision/src/js/30-world.js new file mode 100644 index 000000000..786b8b450 --- /dev/null +++ b/mobile/vision/src/js/30-world.js @@ -0,0 +1,578 @@ +/* World: time, sun, 44-layer catalog, GPU buffers, per-frame update, render, projection, picking. */ +const W = (GE.world = {}); +const FAM = { + sky: { name: 'Sky', color: '#7CC4FF', icon: 'airplane' }, + sea: { name: 'Sea', color: '#38E1C6', icon: 'ferry' }, + orbit: { name: 'Orbit', color: '#C9A7FF', icon: 'satellite' }, + haz: { name: 'Hazards', color: '#FF6A3D', icon: 'flame' }, + eyes: { name: 'Eyes', color: '#FFD166', icon: 'eye' }, + ground: { name: 'Ground', color: '#9BE15D', icon: 'tram' }, + wx: { name: 'Weather', color: '#8AB4F8', icon: 'cloud' }, + infra: { name: 'Infra', color: '#B8C0CC', icon: 'bolt' }, +}; +GE.FAM = FAM; +const hex2rgb = (h) => [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255]; + +/* ---------- layer catalog (44) ---------- */ +const L = (id, fam, name, on, icon) => ({ id, fam, name, on, icon, count: 0, status: 'live' }); +W.layers = [ + L('flights', 'sky', 'Commercial flights', true, 'airplane'), L('military', 'sky', 'Military aircraft', true, 'jet'), L('helis', 'sky', 'Helicopters', true, 'helicopter'), + L('ga', 'sky', 'Private / GA', false, 'propeller'), L('squawk', 'sky', 'Emergency squawks', true, 'exclamation'), L('drones', 'sky', 'Drones (Remote ID)', true, 'drone'), + L('airports', 'sky', 'Airport boards', false, 'board'), L('holding', 'sky', 'Holding patterns', true, 'loop'), + L('ais', 'sea', 'AIS vessels', true, 'ferry'), L('ports', 'sea', 'Ports', false, 'anchor'), L('tankers', 'sea', 'Tankers / cargo', false, 'drop'), + L('fishing', 'sea', 'Fishing effort', false, 'fish'), L('dark', 'sea', 'Dark vessels (AIS gaps)', false, 'eyeslash'), L('cables', 'sea', 'Submarine cables', false, 'cable'), + L('sats', 'orbit', 'Satellites (TLE)', true, 'satellite'), L('crewed', 'orbit', 'ISS & crewed', true, 'iss'), L('trains', 'orbit', 'Starlink trains', true, 'train'), + L('launches', 'orbit', 'Launches & trajectories', true, 'rocket'), L('debris', 'orbit', 'Debris', false, 'debris'), L('tracks', 'orbit', 'Ground tracks & passes', true, 'orbitpath'), + L('quakes', 'haz', 'Earthquakes', true, 'wave'), L('fires', 'haz', 'Active fires (FIRMS)', true, 'flame'), L('perims', 'haz', 'Fire perimeters', false, 'perimeter'), + L('floods', 'haz', 'Floods', true, 'drop'), L('cyclones', 'haz', 'Tropical cyclones', true, 'hurricane'), L('volcanoes', 'haz', 'Volcanoes', false, 'mountain'), + L('aq', 'haz', 'Air quality', false, 'aqi'), + L('cctv', 'eyes', 'Public CCTV', true, 'video'), L('signed', 'eyes', 'Signed captures', true, 'seal'), L('street', 'eyes', 'Street-level imagery', false, 'binoculars'), + L('holo', 'eyes', 'Holograms', true, 'cube'), L('streams', 'eyes', 'Live streams', false, 'live'), + L('transit', 'ground', 'Transit vehicles', false, 'tram'), L('traffic', 'ground', 'Traffic flow', true, 'car'), L('bikes', 'ground', 'Bike share', false, 'bike'), + L('rail', 'ground', 'Rail', false, 'rail'), L('venues', 'ground', 'Events & venues', true, 'ticket'), L('alpr', 'ground', 'ALPR cameras', false, 'plate'), + L('radar', 'wx', 'Radar', true, 'radar'), L('clouds', 'wx', 'Clouds', true, 'cloud'), L('lightning', 'wx', 'Lightning', true, 'bolt'), + L('wind', 'wx', 'Wind', false, 'wind'), L('models', 'wx', 'Forecast models', false, 'spaghetti'), + L('dc', 'infra', 'Datacenters', false, 'server'), L('dams', 'infra', 'Dams', false, 'dam'), L('power', 'infra', 'Power plants', false, 'bolt'), + L('milbase', 'infra', 'Military installations', false, 'shield'), L('gnss', 'infra', 'GNSS interference', true, 'jam'), L('imagery', 'infra', 'Recent satellite imagery', false, 'frame'), +]; +W.layerById = Object.fromEntries(W.layers.map((l) => [l.id, l])); +W.on = (id) => !!W.layerById[id]?.on; + +/* ---------- time ---------- */ +W.offsetH = 0; W.live = true; W.simMs = NOW; W.t = 0; W.sun = astro.sun(NOW); +W.window = null; +W.getWindow = () => W.window || { start: Date.now() - 72 * 3600000, end: Date.now() + 120 * 3600000 }; +W.setWindow = (w) => { W.window = w; bus.emit('window', w); }; +W.setTime = function (ms) { + if (ms == null && !W.window) { W.live = true; W.offsetH = 0; } + else { const win = W.getWindow(); if (ms == null) ms = (win.start + win.end) / 2; ms = clamp(ms, win.start, win.end); W.offsetH = (ms - Date.now()) / 3600000; W.live = !W.window && Math.abs(W.offsetH) < 1 / 120; } + W.simMs = Date.now() + W.offsetH * 3600000; + bus.emit('time', W.offsetH); +}; +W.timeWindow = () => ({ ...W.getWindow(), now: Date.now() }); +W.userHolos = []; + +/* ---------- static data for layers ---------- */ +const RS = rng(77); +const near = (lat, lon, km, n, f) => { const out = []; for (let i = 0; i < n; i++) { const [a, b] = geo.dest(lat, lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f ? f(a, b, i) : [a, b]); } return out; }; +const BIG_CITIES = GE.CITY_V.slice(0, 32); +const PORTS = [[51.95,4.05,'Rotterdam'],[1.26,103.84,'Singapore'],[31.36,121.6,'Shanghai'],[22.3,114.2,'Hong Kong'],[35.1,129.04,'Busan'],[25.0,55.06,'Jebel Ali'],[33.74,-118.26,'Los Angeles / Long Beach'],[53.54,9.97,'Hamburg'],[51.27,4.3,'Antwerp'],[29.6,32.55,'Suez'],[31.25,32.3,'Port Said'],[40.67,-74.05,'New York / NJ'],[-23.96,-46.3,'Santos'],[13.08,100.9,'Laem Chabang'],[22.6,120.27,'Kaohsiung'],[34.6,135.4,'Osaka'],[35.43,139.65,'Yokohama'],[-33.86,151.2,'Sydney'],[47.6,-122.35,'Seattle'],[30.6,122.08,'Ningbo'],[36.1,120.3,'Qingdao'],[9.35,-79.9,'Colón'],[8.95,-79.55,'Balboa'],[-33.9,18.43,'Cape Town'],[6.43,3.38,'Lagos'],[37.94,23.62,'Piraeus'],[44.4,8.9,'Genoa'],[43.3,5.35,'Marseille'],[29.75,-95.0,'Houston'],[19.0,72.85,'Mumbai (JNPT)']]; +const VOLCANOES = [[37.75,14.99,'Etna'],[19.42,-155.29,'Kīlauea'],[31.59,130.66,'Sakurajima'],[19.02,-98.62,'Popocatépetl'],[-7.54,110.44,'Merapi'],[14.47,-90.88,'Fuego'],[-77.53,167.15,'Erebus'],[63.63,-19.62,'Eyjafjallajökull'],[63.9,-22.27,'Reykjanes'],[-0.37,-78.44,'Cotopaxi'],[56.06,160.64,'Klyuchevskoy'],[1.68,127.88,'Ibu'],[-8.06,114.24,'Ijen'],[13.26,123.69,'Mayon'],[46.2,-122.19,'St Helens'],[40.82,14.43,'Vesuvius'],[-39.42,-71.93,'Villarrica'],[-1.52,29.25,'Nyiragongo'],[-15.79,-71.86,'Sabancaya'],[38.79,15.21,'Stromboli']]; +const DCS = [[39.04,-77.49,'Ashburn cluster'],[53.33,-6.4,'Dublin'],[50.11,8.68,'Frankfurt'],[1.3,103.8,'Singapore'],[52.37,4.9,'Amsterdam'],[45.6,-121.2,'The Dalles'],[33.45,-112.07,'Phoenix'],[41.6,-93.6,'Des Moines'],[35.69,139.69,'Tokyo'],[37.37,-121.92,'Santa Clara'],[59.33,18.06,'Stockholm'],[-23.5,-46.6,'São Paulo'],[19.07,72.88,'Mumbai'],[32.78,-96.8,'Dallas']]; +const DAMS = [[30.82,111.0,'Three Gorges'],[36.02,-114.74,'Hoover'],[-25.41,-54.59,'Itaipu'],[23.97,32.88,'Aswan High'],[47.96,-118.98,'Grand Coulee'],[7.76,-62.99,'Guri'],[34.08,72.7,'Tarbela'],[28.27,85.37,'Rasuwagadhi Hydro (approx.)'],[11.2,35.1,'Grand Ethiopian Renaissance'],[46.23,7.37,'Grande Dixence']]; +const MILB = [[31.33,-85.72,'Fort Novosel'],[36.95,-76.33,'Norfolk'],[34.68,-77.34,'Camp Lejeune'],[64.83,-147.6,'Eielson'],[13.58,144.92,'Andersen'],[26.27,127.75,'Kadena'],[49.44,7.6,'Ramstein'],[51.75,-1.58,'Brize Norton'],[37.0,35.42,'Incirlik'],[25.12,51.32,'Al Udeid'],[-7.31,72.41,'Diego Garcia'],[36.24,-115.03,'Nellis']]; +const PADS = [[28.5618,-80.5772,'SLC-40, Cape Canaveral'],[34.632,-120.611,'SLC-4E, Vandenberg'],[45.92,63.34,'Baikonur'],[5.24,-52.77,'Kourou'],[40.96,100.29,'Jiuquan'],[30.4,130.97,'Tanegashima'],[13.72,80.23,'Sriharikota'],[19.61,110.95,'Wenchang'],[25.997,-97.155,'Starbase']]; +const FISH = [[47,-50,6],[-12,-80,6],[18,-18,5],[40,150,7],[57,-170,5],[-45,-62,6],[64,-10,5],[10,-90,5],[-5,95,5],[20,115,5]]; +const GNSS_HOT = [[59.5,24.0,4],[55.5,20.5,3.5],[34.5,33.5,3],[44.5,33.5,3],[33.5,36.0,2.5],[27.0,56.5,2.5],[69.5,30.5,2],[46.5,31.5,2.5]]; +const CABLES = [ + [[50.8,-1.3],[50.0,-10],[47,-30],[43,-50],[40.5,-72]], [[51.5,-9.8],[52.5,-30],[48,-50],[44.5,-63.5]], [[43.3,5.3],[38,10],[34.5,25],[31.3,32.3],[29.9,32.5],[20,38.5],[12.6,43.6],[12,52],[16,62],[19,72.8]], + [[35.4,139.6],[38,155],[42,180],[42,-150],[37,-125],[34.1,-118.4]], [[1.3,103.8],[5,112],[14,119],[22.3,114.2],[30,123],[35.1,129.0]], [[-33.9,151.2],[-25,170],[-10,-170],[10,-160],[21.3,-157.9],[34,-130],[37.7,-122.5]], + [[-23.0,-43.2],[-15,-30],[0,-20],[15,-20],[33,-10],[38.7,-9.2]], [[51.5,-9.8],[53,-20],[60,-20],[64,-22]], [[1.3,103.8],[-5,100],[-20,105],[-32,115.8]], [[6.4,3.4],[0,0],[-10,8],[-25,12],[-34,18.4]], +]; +const RAIL = [[[51.5,-0.13],[51.0,1.9],[50.85,4.35],[52.37,4.9]],[[48.85,2.35],[47.0,4.8],[45.75,4.85],[43.3,5.37]],[[52.52,13.4],[51.34,12.37],[50.11,8.68],[48.14,11.58]],[[35.68,139.77],[35.17,136.9],[34.69,135.5],[34.4,132.46],[33.59,130.4]],[[39.9,116.4],[36.65,117.0],[34.26,117.18],[32.06,118.8],[31.23,121.47]],[[40.75,-73.99],[39.95,-75.16],[39.29,-76.61],[38.9,-77.04]],[[40.42,-3.7],[41.39,2.17]],[[41.9,12.5],[43.77,11.25],[45.46,9.19]]]; + +function cityScatter(n, km, f) { const out = []; for (let i = 0; i < n; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [a, b] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f(a, b, c, i)); } return out; } +// holograms & signed captures (fictional authors). Some are placed on situations. +const HOLOS = [ + { lat: 28.2551, lon: 85.3712, by: 'tamang_lens', title: 'North bank, Timure — water at the road', t: -55 * 24 * 3600 + 0, sit: 'koshi' }, + { lat: 28.1629, lon: 85.3402, by: 'hydro_nerd', title: 'Syaphrubesi bridge deck, 14:31 NPT', t: -55 * 86400, sit: 'koshi' }, + { lat: 28.2802, lon: 85.3797, by: 'geodesy_dan', title: 'Rasuwagadhi — scour line photo', t: -54 * 86400, sit: 'koshi' }, + { lat: 51.5560, lon: -0.2796, by: 'row_m_seat14', title: 'Encore from block 114', t: -3600 * 20, sit: 'concert' }, + { lat: 40.7553, lon: -73.9838, by: 'chopper_cam9', title: 'Tower, 41st floor setback', t: -3600 * 9, sit: 'climb' }, + { lat: 69.65, lon: 18.96, by: 'skywatch_ana', title: 'Corona overhead, Tromsø', t: -3600 * 18, sit: 'aurora' }, + { lat: 29.93, lon: 32.56, by: 'ais_owl', title: 'Anchorage at dusk, 48 ships', t: -3600 * 7, sit: 'suez' }, + { lat: 31.276, lon: -85.713, by: 'rotorwash_ret', title: 'Downwind leg, Cairns', t: -3600 * 30, sit: 'novosel' }, +]; +GE.HOLOS = HOLOS; + +/* ---------- buffers ---------- */ +const SPR_STRIDE = 15; // pos3 col4 par4 ext4 +function pushSpr(a, v, col, size, glyph, hdg = -10, t0 = -1e9, tEnd = 1e9, grow = 0, seed = 0, heat = 0.5) { + a.push(v[0], v[1], v[2], col[0], col[1], col[2], col[3] ?? 1, size, glyph, hdg, t0, tEnd, grow, seed, heat); +} +const LINE_STRIDE = 9; // pos3 col4 d2 +function pushPolyline(a, pts /* [v] */, col, dashed = 0) { + let d = 0; + for (let i = 1; i < pts.length; i++) { + const p0 = pts[i - 1], p1 = pts[i]; + const seg = v3.len(v3.sub(p1, p0)); + a.push(p0[0], p0[1], p0[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed); + d += seg; + a.push(p1[0], p1[1], p1[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed); + } +} +function gcPoints(lat1, lon1, lat2, lon2, n, altKm = 0, lift = 0) { + const a = geo.vec(lat1, lon1), b = geo.vec(lat2, lon2), out = []; + for (let i = 0; i <= n; i++) { const t = i / n; const p = geo.slerp(a, b, t); const r = 1 + (altKm + lift * Math.sin(Math.PI * t)) / EARTH_KM; out.push(v3.scale(p, r)); } + return out; +} +function chainPoints(ll, altKm = 0, sub = 8) { const out = []; for (let i = 1; i < ll.length; i++) { const seg = gcPoints(ll[i - 1][0], ll[i - 1][1], ll[i][0], ll[i][1], sub, altKm); if (i > 1) seg.shift(); out.push(...seg); } return out; } +function circlePts(lat, lon, km, n = 48, altKm = 0) { const out = []; for (let i = 0; i <= n; i++) { const [a, b] = geo.dest(lat, lon, (i / n) * TAU, km); out.push(geo.vec(a, b, altKm)); } return out; } + +W.static = {}; // id → {vao, count, pass:'add'|'alpha', prog:'sprite'|'line'} +function makeSpriteSet(id, arr, pass = 'add') { + const gl = GL.gl, P = GL.progs.sprite, buf = GL.buffer(new Float32Array(arr)); + const S = SPR_STRIDE; + const vao = GL.vao(P, { aPos: [buf, 3, S, 0], aCol: [buf, 4, S, 3], aPar: [buf, 4, S, 7], aExt: [buf, 4, S, 11] }); + return { vao, buf, count: arr.length / S, pass, kind: 'sprite' }; +} +function makeLineSet(arr) { + const P = GL.progs.line, buf = GL.buffer(new Float32Array(arr)); + const vao = GL.vao(P, { aPos: [buf, 3, LINE_STRIDE, 0], aCol: [buf, 4, LINE_STRIDE, 3], aD: [buf, 2, LINE_STRIDE, 7] }); + return { vao, buf, count: arr.length / LINE_STRIDE, kind: 'line' }; +} +function addSet(layer, set) { (W.static[layer] = W.static[layer] || []).push(set); } + +W.buildStatic = function () { + const C = (fam, a = 1) => [...hex2rgb(FAM[fam].color), a]; + // quakes + { const a = []; for (const q of GE.QUAKES) pushSpr(a, geo.vec(q.lat, q.lon, 0.5), [1.0, 0.5, 0.3, 1], 10 + (q.mag - 2.5) * 9, 2, -10, q.t, 1e9, 0, RS(), 0.8); addSet('quakes', makeSpriteSet('quakes', a)); } + // fires (+ smoke in alpha pass) + { const a = [], sm = []; for (const f of GE.FIRES) { pushSpr(a, geo.vec(f.lat, f.lon, 0.2), [1, 0.5, 0.15, 1], 4 + Math.sqrt(f.frp) * 0.9, 3, -10, f.t, 1e9, 0.4, RS(), 1.0); if (RS() < 0.35) pushSpr(sm, geo.vec(f.lat + 0.15, f.lon + 0.35, 2), [0.3, 0.28, 0.27, 0.32], 26 + RS() * 30, 11, -10, f.t + 3600, 1e9, 0.8, RS(), 0.5); } + addSet('fires', makeSpriteSet('fires', a)); addSet('fires', makeSpriteSet('smoke', sm, 'alpha')); } + // perimeters + { const a = []; for (const [la, lo, , , s] of GE.FIRE_CLUSTERS) pushPolyline(a, circlePts(la, lo, s * 111 * 0.9, 40, 0.3), [1, 0.42, 0.24, 0.6], 1); addSet('perims', makeLineSet(a)); } + // floods + { const a = [], b = []; for (const [la, lo] of [[28.21, 85.35], [23.9, 90.6], [26.2, 91.7], [9.0, 7.5], [30.0, 70.9]]) { pushSpr(a, geo.vec(la, lo, 0.2), [0.4, 0.75, 1, 1], 16, 9, -10); pushSpr(b, geo.vec(la, lo, 0.5), [0.25, 0.55, 0.95, 0.35], 60, 11); } addSet('floods', makeSpriteSet('floods', a)); addSet('floods', makeSpriteSet('floods2', b, 'alpha')); } + // volcanoes + { const a = []; for (const [la, lo] of VOLCANOES) pushSpr(a, geo.vec(la, lo, 0.5), [1, 0.45, 0.3, 1], 13, 14); addSet('volcanoes', makeSpriteSet('v', a)); } + // air quality (alpha) + { const a = []; for (const [la, lo] of [[28.6, 77.2], [30.6, 75], [39.9, 116.4], [23.1, 113.3], [24.9, 67], [31.5, 74.3], [6.5, 3.4], [-6.2, 106.8], [40.2, -8.1], [-12, 25], [8, 18]]) for (let i = 0; i < 6; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 260); pushSpr(a, geo.vec(x, y, 1), [0.75, 0.5, 0.25, 0.22], 70 + RS() * 60, 11, -10, -1e9, 1e9, 0, RS()); } addSet('aq', makeSpriteSet('aq', a, 'alpha')); } + // airports (boards) + { const a = []; for (const p of GE.AP) pushSpr(a, geo.vec(p.lat, p.lon, 0.3), C('sky', 0.9), 8 + Math.sqrt(p.w) * 1.4, 13); addSet('airports', makeSpriteSet('ap', a)); } + // ports + { const a = []; for (const [la, lo] of PORTS) pushSpr(a, geo.vec(la, lo, 0.2), C('sea'), 13, 13); addSet('ports', makeSpriteSet('ports', a)); } + // fishing effort (alpha) + { const a = []; for (const [la, lo, s] of FISH) for (let i = 0; i < 26; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.5, lo + RS.gauss() * s, 0.2), [0.2, 0.9, 0.8, 0.12], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); addSet('fishing', makeSpriteSet('fish', a, 'alpha')); } + // dark vessels + { const a = []; for (let i = 0; i < 40; i++) { const h = RS.pick([[26.5, 56.4], [12.5, 43.5], [36.0, 15.0], [44, 33], [1.3, 104.2], [-12, -78], [22, 119]]); pushSpr(a, geo.vec(h[0] + RS.gauss() * 1.2, h[1] + RS.gauss() * 1.5, 0.2), [1, 0.4, 0.4, 0.95], 12, 16); } addSet('dark', makeSpriteSet('dark', a)); } + // cables + { const a = []; for (const c of CABLES) pushPolyline(a, chainPoints(c, -0.5, 10), [0.3, 0.9, 0.85, 0.35]); addSet('cables', makeLineSet(a)); } + // launches: pads + trajectory + { const a = []; for (const [la, lo] of PADS) pushSpr(a, geo.vec(la, lo, 0.3), C('orbit'), 12, 9, -10, -1e9, 1e9, 0, RS()); addSet('launches', makeSpriteSet('pads', a)); + const l = []; const tr = []; for (let i = 0; i <= 40; i++) { const f = i / 40; const [la, lo] = geo.dest(28.5618, -80.5772, 52 * D2R, f * 1900); tr.push(geo.vec(la, lo, Math.pow(f, 0.55) * 210)); } pushPolyline(l, tr, [0.85, 0.7, 1, 0.9], 1); addSet('launches', makeLineSet(l)); } + // cyclone track (past solid, forecast dashed) + forecast cone + model spaghetti + { const a = []; const tr = GE.CYCLONE.track; const past = tr.filter((x) => x[0] <= 0).map((x) => [x[1], x[2]]); const fut = tr.filter((x) => x[0] >= 0).map((x) => [x[1], x[2]]); + pushPolyline(a, chainPoints(past, 1, 6), [1, 0.6, 0.45, 0.95]); pushPolyline(a, chainPoints(fut, 1, 6), [1, 0.75, 0.6, 0.95], 1); + // cone edges + const left = [], right = []; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h), c2 = GE.CYCLONE.at(h + 3); const br = geo.bearing(c.lat, c.lon, c2.lat, c2.lon); const r = GE.CYCLONE.cone(h); left.push(geo.vec(...geo.dest(c.lat, c.lon, br - Math.PI / 2, r), 1)); right.push(geo.vec(...geo.dest(c.lat, c.lon, br + Math.PI / 2, r), 1)); } + pushPolyline(a, left, [1, 0.75, 0.6, 0.5]); pushPolyline(a, right, [1, 0.75, 0.6, 0.5]); + const end = GE.CYCLONE.at(120); pushPolyline(a, circlePts(end.lat, end.lon, GE.CYCLONE.cone(120), 40, 1), [1, 0.75, 0.6, 0.35]); + addSet('cyclones', makeLineSet(a)); + // cone fill as soft sprites + const f = []; for (let h = 3; h <= 120; h += 3) { const c = GE.CYCLONE.at(h); pushSpr(f, geo.vec(c.lat, c.lon, 1), [1, 0.55, 0.4, 0.07], 1, 11, -10, -1e9, 1e9, 0, h); f[f.length - 8] = GE.CYCLONE.cone(h); } + W.coneSprites = f; // sized per-frame (km → px) + const m = []; for (let k = 0; k < 14; k++) { const pts = []; let la = GE.CYCLONE.at(0).lat, lo = GE.CYCLONE.at(0).lon; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h); const sp = h / 120; pts.push([c.lat + Math.sin(k * 1.7) * sp * 4.5 + RS.gauss() * sp, c.lon + Math.cos(k * 2.3) * sp * 6 + RS.gauss() * sp]); } pushPolyline(m, chainPoints(pts, 1, 4), [0.6, 0.75, 1, 0.45]); } + addSet('models', makeLineSet(m)); } + // eyes + { const a = cityScatter(320, 18, (la, lo) => la); const s = []; for (let i = 0; i < 320; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 16); pushSpr(s, geo.vec(x, y, 0.05), C('eyes'), 13, 5); } + // Midtown / Wembley / Chicago extra cameras for close-ups + for (const [la, lo, n] of [[40.7549, -73.984, 40], [51.556, -0.2796, 18], [41.8789, -87.6359, 30], [28.163, 85.34, 4]]) for (let i = 0; i < n; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, 0.15 + RS() * 1.6); pushSpr(s, geo.vec(x, y, 0.02), C('eyes'), 15, 5); } + addSet('cctv', makeSpriteSet('cctv', s)); } + { const s = []; for (let i = 0; i < 70; i++) { const c = BIG_CITIES[(i * 7) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 30); pushSpr(s, geo.vec(x, y, 0.05), [1, 0.82, 0.4, 1], 12, 6, -10, -1e9, 1e9, 0, RS(), 0.4); } for (const hh of HOLOS) pushSpr(s, geo.vec(hh.lat, hh.lon, 0.05), [1, 0.82, 0.4, 1], 15, 6); addSet('signed', makeSpriteSet('signed', s)); } + { const s = cityScatter(1800, 22, () => 0); const a = []; for (let i = 0; i < 1800; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 20); pushSpr(a, geo.vec(x, y, 0.02), [1, 0.9, 0.6, 0.45], 3.2, 0); } addSet('street', makeSpriteSet('street', a)); } + { const a = [], l = []; for (const hh of HOLOS) { pushSpr(a, geo.vec(hh.lat, hh.lon, 0.35), [1, 0.85, 0.45, 1], 22, 6, -10, -1e9, 1e9, 0, RS(), 0.4); pushPolyline(l, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.35)], [1, 0.85, 0.5, 0.9]); } addSet('holo', makeSpriteSet('holo', a)); addSet('holo', makeLineSet(l)); } + { const a = []; for (let i = 0; i < 36; i++) { const c = BIG_CITIES[(i * 5) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 25); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.35, 0.4, 1], 12, 9, -10, -1e9, 1e9, 0, RS()); } addSet('streams', makeSpriteSet('streams', a)); } + // ground + { const a = []; for (let i = 0; i < 1500; i++) { const c = BIG_CITIES[i % 20]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 14); pushSpr(a, geo.vec(x, y, 0.02), C('ground', 0.9), 3.5, 0); } addSet('bikes', makeSpriteSet('bikes', a)); } + { const a = []; for (const r of RAIL) pushPolyline(a, chainPoints(r, 0.1, 6), [0.62, 0.88, 0.37, 0.65]); addSet('rail', makeLineSet(a)); } + { const a = []; for (const [la, lo] of [[51.556, -0.2796], [40.7505, -73.9934], [41.8623, -87.6167], [34.0141, -118.2879], [48.8414, 2.253], [40.4531, -3.6883], [35.6681, 139.7], [-33.891, 151.2249], [53.4631, -2.2913], [41.3809, 2.1228]]) pushSpr(a, geo.vec(la, lo, 0.1), C('ground'), 14, 9, -10, -1e9, 1e9, 0, RS()); addSet('venues', makeSpriteSet('venues', a)); } + { const a = []; for (let i = 0; i < 300; i++) { const c = BIG_CITIES[[0, 15, 16, 26, 24][i % 5]]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 30); pushSpr(a, geo.vec(x, y, 0.02), [0.75, 0.9, 0.6, 0.85], 7, 13); } addSet('alpr', makeSpriteSet('alpr', a)); } + // weather: radar blobs + lightning + { const a = [], b = []; const cy = GE.CYCLONE.at(0); + for (let i = 0; i < 70; i++) { const ang = RS() * TAU, rr = Math.pow(RS(), 0.7) * 480; const [x, y] = geo.dest(cy.lat, cy.lon, ang, rr); pushSpr(a, geo.vec(x, y, 1), rr < 150 ? [1, 0.75, 0.2, 0.16] : [0.3, 0.85, 0.4, 0.11], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); if (RS() < 0.5) pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 18, 12, -10, -1e9, 1e9, 0, RS(), 0.9); } + for (const [la, lo] of [[45, -95], [5, 20], [-5, -60], [10, 100], [32, -85]]) for (let i = 0; i < 18; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 500); pushSpr(a, geo.vec(x, y, 1), [0.3, 0.85, 0.4, 0.1], 24 + RS() * 30, 11, -10, -1e9, 1e9, 0, RS()); pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 14, 12, -10, -1e9, 1e9, 0, RS(), 0.9); } + addSet('radar', makeSpriteSet('radar', a, 'alpha')); addSet('lightning', makeSpriteSet('lightning', b)); } + // infra + { const a = []; for (const [la, lo] of DCS) pushSpr(a, geo.vec(la, lo, 0.1), C('infra'), 13, 13); addSet('dc', makeSpriteSet('dc', a)); } + { const a = []; for (const [la, lo] of DAMS) pushSpr(a, geo.vec(la, lo, 0.1), [0.6, 0.8, 1, 1], 13, 13); addSet('dams', makeSpriteSet('dams', a)); } + { const a = []; for (let i = 0; i < 260; i++) { const c = RS.pick(GE.AP); const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, 20 + RS() * 120); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.85, 0.5, 0.8], 6, 0); } addSet('power', makeSpriteSet('power', a)); } + { const a = []; for (const [la, lo] of MILB) pushSpr(a, geo.vec(la, lo, 0.1), [1, 0.6, 0.4, 0.9], 13, 13); addSet('milbase', makeSpriteSet('mb', a)); } + { const a = []; for (const [la, lo, s] of GNSS_HOT) for (let i = 0; i < 9; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.25, lo + RS.gauss() * s * 0.45, 1), [1, 0.32, 0.36, 0.22], 18 + s * 4, 10, -10, -1e9, 1e9, 0, RS(), 0.6); addSet('gnss', makeSpriteSet('gnss', a, 'alpha')); } + { const a = []; for (let i = 0; i < 40; i++) { const c = RS.pick(GE.AP); const la = c.lat + RS.gauss() * 2, lo = c.lon + RS.gauss() * 2, w = 0.5 + RS() * 0.6; const q = [[la - w, lo - w], [la - w, lo + w], [la + w, lo + w], [la + w, lo - w], [la - w, lo - w]].map(([x, y]) => geo.vec(x, y, 0.5)); pushPolyline(a, q, [0.8, 0.85, 0.95, 0.5]); } addSet('imagery', makeLineSet(a)); } + // AIS lanes as faint context lines + { const a = []; for (const l of GE.LANES) pushPolyline(a, chainPoints(l, 0, 6), [0.22, 0.88, 0.78, 0.07]); addSet('ais', makeLineSet(a)); } + // situation pins handled dynamically + W.counts.quakes = GE.QUAKES.length; W.counts.ports = PORTS.length; W.counts.volcanoes = VOLCANOES.length; W.counts.dc = DCS.length; W.counts.dams = DAMS.length; W.counts.milbase = MILB.length; W.counts.cables = CABLES.length; + W.counts.launches = PADS.length; W.counts.cctv = 408; W.counts.signed = 78; W.counts.street = 1800; W.counts.holo = HOLOS.length; W.counts.streams = 36; W.counts.bikes = 1500; W.counts.rail = 412; W.counts.venues = 10; W.counts.alpr = 300; + W.counts.radar = 160; W.counts.clouds = 1; W.counts.models = 14; W.counts.power = 260; W.counts.gnss = 8; W.counts.imagery = 40; W.counts.fishing = 260; W.counts.dark = 40; W.counts.aq = 66; W.counts.floods = 5; W.counts.perims = GE.FIRE_CLUSTERS.length; +}; +W.counts = {}; + +/* ---------- GPU flights & satellites ---------- */ +W.buildMovers = function () { + const gl = GL.gl; + const F = GE.FLIGHTS, n = F.length, a = new Float32Array(n * 14); + F.forEach((f, i) => { const o = i * 14; a.set(f.a.v, o); a.set(f.b.v, o + 3); a[o + 6] = f.t0; a[o + 7] = f.dur; a[o + 8] = f.period; a[o + 9] = f.turn; a[o + 10] = f.alt; a[o + 11] = f.kind; a[o + 12] = hash01(i); a[o + 13] = f.dKm; }); + const fb = GL.buffer(a); + const spec = (P) => ({ aA: [fb, 3, 14, 0, 1], aB: [fb, 3, 14, 3, 1], aTime: [fb, 4, 14, 6, 1], aMeta: [fb, 4, 14, 10, 1] }); + W.flightVao = GL.vao(GL.progs.flight, spec()); + const K = 28, kb = GL.buffer(new Float32Array(Array.from({ length: K }, (_, i) => i / (K - 1)))); + const ts = spec(); ts.aK = [kb, 1, 1, 0, 0]; + W.trailVao = GL.vao(GL.progs.trail, ts); W.trailK = K; W.nFlights = n; + const S = GE.SATS, sa = new Float32Array(S.length * 8); + S.forEach((s, i) => { sa.set([s.inc, s.raan, s.u0, s.n, s.alt, s.kind, s.geo ? 1 : 0, hash01(i + 7)], i * 8); }); + const sb = GL.buffer(sa); + W.satVao = GL.vao(GL.progs.sat, { aEl: [sb, 4, 8, 0], aSM: [sb, 4, 8, 4] }); W.nSats = S.length; + // dynamic sprites / lines + W.dynBuf = gl.createBuffer(); W.dynVao = null; W.dynCount = 0; W.dynCap = 0; + W.dynLineBuf = gl.createBuffer(); W.dynLineCount = 0; W.dynLineCap = 0; + W.alphaBuf = gl.createBuffer(); W.alphaCount = 0; W.alphaCap = 0; +}; +function uploadDyn(bufKey, vaoKey, capKey, countKey, arr, kind) { + const gl = GL.gl; const f = new Float32Array(arr); + gl.bindBuffer(gl.ARRAY_BUFFER, W[bufKey]); + if (f.length > W[capKey]) { W[capKey] = Math.max(f.length, W[capKey] * 1.5) | 0; gl.bufferData(gl.ARRAY_BUFFER, W[capKey] * 4, gl.DYNAMIC_DRAW); + if (kind === 'line') W[vaoKey] = GL.vao(GL.progs.line, { aPos: [W[bufKey], 3, LINE_STRIDE, 0], aCol: [W[bufKey], 4, LINE_STRIDE, 3], aD: [W[bufKey], 2, LINE_STRIDE, 7] }); + else W[vaoKey] = GL.vao(GL.progs.sprite, { aPos: [W[bufKey], 3, SPR_STRIDE, 0], aCol: [W[bufKey], 4, SPR_STRIDE, 3], aPar: [W[bufKey], 4, SPR_STRIDE, 7], aExt: [W[bufKey], 4, SPR_STRIDE, 11] }); } + gl.bufferSubData(gl.ARRAY_BUFFER, 0, f); + W[countKey] = f.length / (kind === 'line' ? LINE_STRIDE : SPR_STRIDE); +} + +/* ---------- CPU movers: helicopters, drones, holding, transit, wind, runners ---------- */ +const HELI_CIRCUITS = []; +(function () { + // Fort Novosel: Cairns AAF + stagefields — rectangular circuits (illustrative) + const fields = [[31.276, -85.713, 'Cairns'], [31.36, -85.63, 'Stagefield A'], [31.20, -85.81, 'Stagefield B'], [31.42, -85.79, 'Stagefield C']]; + let k = 0; + for (const [la, lo, nm] of fields) for (let i = 0; i < (nm === 'Cairns' ? 7 : 4); i++) HELI_CIRCUITS.push({ lat: la, lon: lo, w: 1.6 + RS() * 0.6, h: 0.9 + RS() * 0.3, rot: (nm === 'Cairns' ? 0.06 : RS() * 0.6), alt: 0.15 + RS() * 0.04, period: 330 + RS() * 60, ph: i / 7 + RS() * 0.05, cs: ['HAWK', 'LAKOTA', 'ROTOR', 'TALON'][k % 4] + ' ' + (21 + k), field: nm, type: k % 3 ? 'UH-72' : 'TH-73', idx: k++ }); + // news helicopters over the tower (orbit), and city police/EMS + for (let i = 0; i < 3; i++) HELI_CIRCUITS.push({ orbit: true, lat: 40.7553, lon: -73.9838, r: 0.55 + i * 0.25, alt: 0.33 + i * 0.06, period: 140 + i * 30, ph: i * 0.33, cs: ['N9 NEWS', 'SKY 4', 'EYE 2'][i] + ' (illustrative)', type: 'AS350', idx: k++ }); + for (let i = 0; i < 18; i++) { const c = BIG_CITIES[i]; HELI_CIRCUITS.push({ orbit: true, lat: c.lat + RS.gauss() * 0.05, lon: c.lon + RS.gauss() * 0.05, r: 1 + RS() * 3, alt: 0.3 + RS() * 0.3, period: 200 + RS() * 300, ph: RS(), cs: 'MED ' + (100 + i), type: 'EC135', idx: k++ }); } +})(); +GE.HELI_CIRCUITS = HELI_CIRCUITS; +function heliAt(hc, t) { + const ph = ((t / hc.period + hc.ph) % 1 + 1) % 1; + if (hc.orbit) { const ang = ph * TAU; const [la, lo] = geo.dest(hc.lat, hc.lon, ang, hc.r); return { lat: la, lon: lo, altKm: hc.alt, hdg: ang + Math.PI / 2, v: geo.vec(la, lo, hc.alt), spd: (TAU * hc.r * 1000) / hc.period * 1.944 }; } + // rectangle circuit: perimeter param + const W2 = hc.w, H2 = hc.h, P = 2 * (W2 + H2); let d = ph * P; let x, y, hd; + if (d < W2) { x = d - W2 / 2; y = -H2 / 2; hd = Math.PI / 2; } else if ((d -= W2) < H2) { x = W2 / 2; y = d - H2 / 2; hd = 0; } else if ((d -= H2) < W2) { x = W2 / 2 - d; y = H2 / 2; hd = -Math.PI / 2; } else { d -= W2; x = -W2 / 2; y = H2 / 2 - d; hd = Math.PI; } + // round corners a bit + const c = Math.cos(hc.rot), s = Math.sin(hc.rot); const ex = x * c - y * s, ny = x * s + y * c; + const [la, lo] = geo.offset(hc.lat, hc.lon, ex * 1000, ny * 1000); + // altitude: descend on final (last quarter), climb after takeoff + const alt = hc.alt * (0.35 + 0.65 * smooth(0.0, 0.12, ph) * smooth(1.0, 0.86, ph)); + return { lat: la, lon: lo, altKm: alt, hdg: hd + hc.rot * -1, v: geo.vec(la, lo, alt), spd: (P * 1000 / hc.period) * 1.944 }; +} +GE.heliAt = heliAt; +const HOLD_STACKS = [[51.72, -0.55, 'BNN'], [51.30, -0.45, 'OCK'], [51.33, 0.03, 'BIG'], [51.65, 0.15, 'LAM'], [40.9, -73.2, 'CCC'], [33.9, -84.0, 'ATL-E']]; +const HOLDERS = []; HOLD_STACKS.forEach(([la, lo, nm], si) => { for (let i = 0; i < (si < 4 ? 5 : 3); i++) HOLDERS.push({ la, lo, nm, lvl: 7 + i * 1, ph: RS(), cs: GE.FLIGHTS[(si * 13 + i * 7) % 3000].cs }); }); +function holdAt(hd, t) { + const per = 240, ph = ((t / per + hd.ph) % 1 + 1) % 1; // racetrack 4 min + const L2 = 7, Rr = 2.2; const P = 2 * L2 + TAU * Rr; let d = ph * P; let x, y, hh; + if (d < L2) { x = d - L2 / 2; y = Rr; hh = Math.PI / 2; } else if ((d -= L2) < Math.PI * Rr) { const a = d / Rr; x = L2 / 2 + Math.sin(a) * Rr; y = Math.cos(a) * Rr; hh = Math.PI / 2 + a; } else if ((d -= Math.PI * Rr) < L2) { x = L2 / 2 - d; y = -Rr; hh = -Math.PI / 2; } else { d -= L2; const a = d / Rr; x = -L2 / 2 - Math.sin(a) * Rr; y = -Math.cos(a) * Rr; hh = -Math.PI / 2 + a; } + const [la, lo] = geo.offset(hd.la, hd.lo, x * 1000, y * 1000); + return { lat: la, lon: lo, altKm: hd.lvl * 0.3048, hdg: hh, v: geo.vec(la, lo, hd.lvl * 0.3048) }; +} +// delivery-drone corridor (illustrative, suburban Texas) +const DRONE_DEPOT = [30.5880, -96.2900]; +const DRONES = []; for (let i = 0; i < 16; i++) { const brg = RS() * TAU, km = 1.2 + RS() * 4.5; DRONES.push({ to: geo.dest(DRONE_DEPOT[0], DRONE_DEPOT[1], brg, km), km, period: 600 + RS() * 400, ph: RS(), id: 'RID-' + (4400 + i) }); } +GE.DRONES = DRONES; GE.DRONE_DEPOT = DRONE_DEPOT; +function droneAt(d, t) { + const ph = ((t / d.period + d.ph) % 1 + 1) % 1; const out = ph < 0.5; const f = out ? ph * 2 : (1 - ph) * 2; + const s = smooth(0, 1, f); const la = lerp(DRONE_DEPOT[0], d.to[0], s), lo = lerp(DRONE_DEPOT[1], d.to[1], s); + const alt = 0.06 * Math.min(1, Math.min(f, 1 - f) * 12 + 0.05); + return { lat: la, lon: lo, altKm: alt, v: geo.vec(la, lo, alt), hdg: geo.bearing(DRONE_DEPOT[0], DRONE_DEPOT[1], d.to[0], d.to[1]) + (out ? 0 : Math.PI) }; +} +// marathon (fictional race) — runners on a lakeshore loop in Chicago +const RUN_ROUTE = [[41.8827, -87.6233], [41.8930, -87.6240], [41.9100, -87.6330], [41.9280, -87.6380], [41.9400, -87.6440], [41.9280, -87.6420], [41.9100, -87.6300], [41.8960, -87.6180], [41.8800, -87.6150], [41.8670, -87.6170], [41.8520, -87.6200], [41.8380, -87.6080], [41.8520, -87.6150], [41.8680, -87.6230], [41.8827, -87.6233]]; +const RUN_V = chainPoints(RUN_ROUTE, 0.002, 6); const RUN_CUM = [0]; for (let i = 1; i < RUN_V.length; i++) RUN_CUM.push(RUN_CUM[i - 1] + v3.len(v3.sub(RUN_V[i], RUN_V[i - 1]))); +const RUNNERS = []; for (let i = 0; i < 260; i++) RUNNERS.push({ pace: 0.9 + Math.pow(RS(), 1.6) * 1.1, off: RS() * 0.18, lane: RS.gauss() * 0.4, bib: 1000 + i }); +GE.RUN_ROUTE = RUN_ROUTE; +function runnerAt(r, t) { + const L = RUN_CUM[RUN_CUM.length - 1]; let d = ((r.off + (t / 3600) * 3.2 / r.pace / (L * EARTH_KM / 1)) % 1 + 1) % 1 * L; + let i = 1; while (i < RUN_CUM.length - 1 && RUN_CUM[i] < d) i++; + const f = (d - RUN_CUM[i - 1]) / ((RUN_CUM[i] - RUN_CUM[i - 1]) || 1); const v = v3.lerp(RUN_V[i - 1], RUN_V[i], f); return v; +} +// wind particles (zonal jets + cyclone swirl) +const WIND = []; for (let i = 0; i < 1400; i++) WIND.push({ lat: Math.asin(RS() * 2 - 1) * R2D * 0.95, lon: RS() * 360 - 180, ph: RS(), life: 5400 + RS() * 5400 }); +function windVel(lat, lon, t) { // deg/hour + const jet = 34 * Math.exp(-Math.pow((Math.abs(lat) - 45) / 10, 2)) - 8 * Math.exp(-Math.pow(lat / 14, 2)) + 6 * Math.sin(lon * D2R * 3 + t / 40000); + let u = jet * 3.6 / 111 / Math.max(0.2, Math.cos(lat * D2R)), v = 4 * Math.sin(lon * D2R * 2 + lat * D2R * 3) * 3.6 / 111; + const c = GE.CYCLONE.at(W.offsetH); const dlat = lat - c.lat, dlon = (lon - c.lon) * Math.cos(lat * D2R); const r = Math.hypot(dlat, dlon); + if (r < 12) { const s = 60 * Math.exp(-r / 3.5) * (1 - Math.exp(-r * r / 0.5)); u += (-dlat / (r + 1e-3)) * s * 3.6 / 111; v += (dlon / (r + 1e-3)) * s * 3.6 / 111; } + return [u, v]; +} + +W.squawks = [41, 1207].map((i) => i % GE.FLIGHTS.length); +W.situationPins = []; // filled by situations module + +W.updateDynamic = function () { + const t = W.t, a = [], al = [], ln = []; + // ships (AIS) — color by kind when tankers layer on + const showAis = W.on('ais'), showTk = W.on('tankers'); + if (showAis || showTk) { + if (!W._shipCache || Math.abs(W._shipCacheT - t) > 6 || W._shipCacheN !== GE.SHIPS.length) { W._shipCache = GE.SHIPS.map((s) => shipAt(s, t)); W._shipCacheT = t; W._shipCacheN = GE.SHIPS.length; } + GE.SHIPS.forEach((s, i) => { + const p = W._shipCache[i]; const tank = s.kind === 'tanker' || s.kind === 'LNG'; + if (!showAis && !tank) return; + const ak = clamp(lerp(0.32, 0.95, smooth(1.2, 0.08, W.lastRange ?? 1)), 0.3, 1); + const col = showTk && tank ? [1, 0.7, 0.35, ak] : s.queue ? [0.5, 1, 0.9, ak] : [0.22, 0.88, 0.78, ak * 0.95]; + pushSpr(a, geo.vec(p.lat, p.lon, 0.03), col, (showTk && tank ? 10 : 8.5), 4, p.hdg, -1e9, 1e9, 0, i, 0.35); + }); + } + if (W.on('helis')) for (const hc of HELI_CIRCUITS) { const p = heliAt(hc, t); pushSpr(a, p.v, [0.62, 1, 0.6, 1], 17, 8, p.hdg, -1e9, 1e9, 0, hc.idx, 0.7); } + if (W.on('holding')) for (const hd of HOLDERS) { const p = holdAt(hd, t); pushSpr(a, p.v, [0.56, 0.82, 1, 1], 13, 1, p.hdg, -1e9, 1e9, 0, 0, 0.55); } + if (W.on('drones')) for (const d of DRONES) { const p = droneAt(d, t); pushSpr(a, p.v, [0.75, 1, 0.95, 1], 13, 17, p.hdg, -1e9, 1e9, 0, 0, 0.5); } + if (W.on('squawk')) for (const i of W.squawks) { const p = flightAt(GE.FLIGHTS[i], t); if (p) pushSpr(a, v3.scale(p.v, 1 + p.altKm * W.altX / EARTH_KM), [1, 0.25, 0.25, 1], 30, 9, -10, -1e9, 1e9, 0, i * 0.37, 0.8); } + if (W.on('venues') || W.on('transit')) for (const r of RUNNERS) pushSpr(a, runnerAt(r, t), [0.62, 0.9, 0.37, 1], 6, 16, -10, -1e9, 1e9, 0, 0, 0.6); + if (W.on('transit')) for (let i = 0; i < 400; i++) { const c = BIG_CITIES[i % 16]; const ang = hash01(i) * TAU + t / (600 + hash01(i + 3) * 900); const rr = 2 + hash01(i + 9) * 10; const [la, lo] = geo.dest(c.lat, c.lon, ang, rr); pushSpr(a, geo.vec(la, lo, 0.01), [0.62, 0.9, 0.37, 0.95], 5, 0); } + if (W.on('wind')) for (const w of WIND) { const age = ((t / w.life + w.ph) % 1 + 1) % 1; let la = w.lat, lo = w.lon; const steps = 4, dtH = (age * w.life) / 3600 / steps; for (let k = 0; k < steps; k++) { const [u, v] = windVel(la, lo, t); la += v * dtH; lo += u * dtH; } const [u, v] = windVel(la, lo, t); const fade = Math.sin(age * Math.PI); pushSpr(a, geo.vec(la, lo, 3), [0.75, 0.85, 1, 0.55 * fade], 14, 15, Math.atan2(u * Math.cos(la * D2R), v), -1e9, 1e9, 0, 0, 0.1); } + for (const hh of W.userHolos) pushSpr(a, geo.vec(hh.lat, hh.lon, 0.06), [1, 0.85, 0.45, 1], 24, 6, -10, -1e9, 1e9, 0, 0.2, 0.4); + if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - (Date.now() - 10.5 * 3600000)) / (1.9 * 3600000), 0, 1); const [la, lo] = geo.offset(40.7553, -73.9838, 16, 23.5); pushSpr(a, geo.vec(la, lo, (12 + f * 372) / 1000), [1, 0.3, 0.3, 1], 16, 9, -10, -1e9, 1e9, 0, 0.7, 0.9); } + // situation pins + for (const p of W.situationPins) pushSpr(a, geo.vec(p.lat, p.lon, 0.2), p.col, p.size || 16, 9, -10, -1e9, 1e9, 0, p.seed || 0, 0.6); + // selection highlight + if (W.sel && W.sel.v) pushSpr(a, W.sel.v, [1, 0.85, 0.45, 1], 34, 9, -10, -1e9, 1e9, 0, 0.5, 0.6); + // cyclone cone fill (alpha) + if (W.on('cyclones') && W.coneSprites) { const f = W.coneSprites.slice(); const kmPerPx = W.kmPerPx || 10; for (let i = 0; i < f.length; i += SPR_STRIDE) { f[i + 7] = clamp((f[i + 7] * 2) / kmPerPx, 1, 600) / Math.max(W.sizeK, 0.1); } al.push(...f); } + uploadDyn('dynBuf', 'dynVao', 'dynCap', 'dynCount', a, 'sprite'); + uploadDyn('alphaBuf', 'alphaVao', 'alphaCap', 'alphaCount', al, 'sprite'); + // dynamic lines: ISS ground track, selected route, extra lines from UI + if (W.on('tracks')) { + const pts = []; for (let k = -46; k <= 46; k++) { const tt = t + k * 60; const s = astro.sun(NOW + tt * 1000); const p = satAt(GE.ISS, tt, s.gmst); pts.push(v3.scale(v3.norm(p), 1.0005)); } + pushPolyline(ln, pts, [0.79, 0.66, 1, 0.55], 1); + const op = []; for (let k = 0; k <= 64; k++) { const tt = t + (k / 64) * (TAU / GE.ISS.n); op.push(satAt(GE.ISS, tt, W.sun.gmst)); } pushPolyline(ln, op, [0.79, 0.66, 1, 0.4]); + } + if (W.selRoute) pushPolyline(ln, W.selRoute, [1, 0.85, 0.45, 0.8], 1); + for (const extra of W.extraLines) pushPolyline(ln, extra.pts, extra.col, extra.dashed || 0); + for (const hh of W.userHolos) pushPolyline(ln, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.06)], [1, 0.85, 0.5, 0.9]); + uploadDyn('dynLineBuf', 'dynLineVao', 'dynLineCap', 'dynLineCount', ln, 'line'); +}; +W.extraLines = []; + +/* ---------- counts ---------- */ +W.updateCounts = function () { + const t = W.t; let c0 = 0, c1 = 0, c3 = 0; + for (const f of GE.FLIGHTS) { let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period; const lp = ph < f.period / 2 ? ph : ph - f.period / 2; if (lp <= f.dur) { if (f.kind === 0) c0++; else if (f.kind === 1) c1++; else c3++; } } + const C = W.counts; + C.flights = c0; C.military = c1; C.ga = c3; C.helis = HELI_CIRCUITS.length; C.squawk = W.squawks.length; C.drones = DRONES.length; C.airports = GE.AP.length; C.holding = HOLDERS.length; + C.ais = GE.SHIPS.length; C.tankers = GE.SHIPS.filter((s) => s.kind === 'tanker' || s.kind === 'LNG').length; + C.sats = GE.SATS.filter((s) => s.kind <= 3).length; C.crewed = 2; C.trains = 1; C.debris = GE.SATS.filter((s) => s.kind === 4).length; C.tracks = 1; + C.fires = GE.FIRES.filter((f) => f.t <= t).length; C.quakes = GE.QUAKES.filter((q) => q.t <= t && q.t > t - 86400 * 3).length; + C.cyclones = 1; C.lightning = 160 + Math.round(40 * Math.sin(t / 900)); C.wind = 1400; C.transit = 400; C.traffic = 1; + for (const l of W.layers) l.count = C[l.id] ?? 0; + bus.emit('layers', W.layers); +}; + +/* ---------- textures ---------- */ +W.loadTextures = async function () { + const T = window.__GE_TEX || {}; + const load = (src) => new Promise((res, rej) => { const im = new Image(); im.decoding = 'async'; im.onload = () => res(im); im.onerror = rej; im.src = src; }); + const [day, night, clouds, water, topo] = await Promise.all(['day', 'night', 'clouds', 'water', 'topo'].map((k) => load(T[k]))); + W.img = { day, night, clouds, water, topo }; + W.tex = { day: GL.texture(day), night: GL.texture(night), clouds: GL.texture(clouds), water: GL.texture(water, { aniso: 2 }), topo: GL.texture(topo, { mips: true, aniso: 0 }) }; + // CPU copies for density queries (lights) and thumbnails fallback + const cv = document.createElement('canvas'); cv.width = 1024; cv.height = 512; const cx = cv.getContext('2d', { willReadFrequently: true }); + cx.drawImage(night, 0, 0, 1024, 512); W.nightPx = cx.getImageData(0, 0, 1024, 512).data; + cx.drawImage(water, 0, 0, 1024, 512); W.waterPx = cx.getImageData(0, 0, 1024, 512).data; +}; +W.lightsAt = function (lat, lon) { + if (!W.nightPx) return 0; + const x = ((lon + 180) / 360) * 1024, y = ((90 - lat) / 180) * 512; + const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0; + const s = (i, j) => { i = ((i % 1024) + 1024) % 1024; j = clamp(j, 0, 511); const o = (j * 1024 + i) * 4; return Math.max(0, (W.nightPx[o + 1] * 1.1 - W.nightPx[o + 2] * 0.55) / 255); }; + return lerp(lerp(s(x0, y0), s(x0 + 1, y0), fx), lerp(s(x0, y0 + 1), s(x0 + 1, y0 + 1), fx), fy); +}; +W.waterAt = function (lat, lon) { if (!W.waterPx) return 0; const x = Math.floor(((lon + 180) / 360) * 1024) % 1024, y = clamp(Math.floor(((90 - lat) / 180) * 512), 0, 511); return W.waterPx[(y * 1024 + x) * 4] / 255; }; + +/* ---------- lens ---------- */ +W.lens = 'cinematic'; +const LENS = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 }; +W.setLens = (id) => { W.lens = id; bus.emit('lens', id); }; + +/* ---------- view helpers ---------- */ +// view: {eye, right, up, back, fovY, aspect, near, far, shiftX, shiftY, w, h} +W.matrices = function (view) { + const P = m4.persp(view.fovY, view.aspect, view.near, view.far); + P[8] = -(view.shiftX || 0); P[9] = -(view.shiftY || 0); + const V = m4.viewRot(view.right, view.up, view.back); + view.P = P; view.V = V; view.VP = m4.mul(P, V); + return view; +}; +W.project = function (view, p) { // world unit coords → css px {x,y,z,vis} + const rel = v3.sub(p, view.eye); const c = m4.xform(view.VP, rel); + if (c[3] <= 0) return null; + const x = c[0] / c[3], y = c[1] / c[3]; + // occlusion by planet + const E = view.eye, d = rel, L2 = v3.len(d), u = v3.scale(d, 1 / L2), b = v3.dot(E, u), cc = v3.dot(E, E) - 0.9985 * 0.9985, disc = b * b - cc; + let occ = false; if (disc > 0) { const tn = -b - Math.sqrt(disc); occ = tn > 0 && tn < L2; } + return { x: (x * 0.5 + 0.5) * view.w, y: (1 - (y * 0.5 + 0.5)) * view.h, d: L2, vis: !occ && Math.abs(x) < 1.2 && Math.abs(y) < 1.2 }; +}; + +/* ---------- render ---------- */ +W.quality = { scale: 1, steps: 12, bloom: true, grain: true }; +const LOD = { cctv: 0.07, street: 0.03, alpr: 0.035, bikes: 0.04, streams: 0.4, signed: 0.6, venues: 0.9, power: 0.7, dc: 1.6, dams: 1.6, milbase: 1.6, airports: 1.3, ports: 1.4, imagery: 1.6, fishing: 2.2, radar: 1.6 }; +W.render = function (view, chain, outFb, opts = {}) { + const gl = GL.gl, PR = GL.progs; + W.matrices(view); + const sun = opts.sun || W.sun; + const sunDir = sun.dir; + const t = opts.t ?? W.t; + const eye = view.eye, eyeR = v3.len(eye), alt = eyeR - 1; + const eyeC = (eyeR - 1) * (eyeR + 1); + const eyeH = eye.map(Math.fround), eyeL = [eye[0] - eyeH[0], eye[1] - eyeH[1], eye[2] - eyeH[2]]; + const lensId = opts.lens || W.lens; const mode = LENS[lensId] ?? 0; + const range = view.range ?? alt; + const tanY = Math.tan(view.fovY / 2), tanX = tanY * view.aspect; + gl.bindFramebuffer(gl.FRAMEBUFFER, chain.scene.fb); + gl.viewport(0, 0, chain.w, chain.h); + gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.depthMask(true); + gl.clearColor(0, 0, 0, 0); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + // ---- globe + const G = PR.globe; gl.useProgram(G.p); + gl.uniform3fv(G.u.uCamR, view.right); gl.uniform3fv(G.u.uCamU, view.up); gl.uniform3fv(G.u.uCamB, view.back); + gl.uniform4f(G.u.uTanS, tanX, tanY, view.shiftX || 0, view.shiftY || 0); + gl.uniform3fv(G.u.uEye, eye); gl.uniform1f(G.u.uEyeC, eyeC); gl.uniform1f(G.u.uT, t); gl.uniform1f(G.u.uGmst, sun.gmst); + gl.uniform3fv(G.u.uSun, sunDir); gl.uniform1f(G.u.uSunI, 20.0); + const cloudsOn = W.on('clouds') ? 1 : 0; + gl.uniform4f(G.u.uLens, mode, mode === 2 ? 1 : opts.nightForce || 0, cloudsOn * (opts.cloudK ?? 1), mode === 1 ? 1 : 0); + // cyclone + const cyc = GE.CYCLONE.at(opts.offsetH ?? W.offsetH); const cv = geo.vec(cyc.lat, cyc.lon); + gl.uniform4f(G.u.uCyc, cv[0], cv[1], cv[2], 5.2 * D2R); + gl.uniform4f(G.u.uCycP, W.on('cyclones') ? clamp(cyc.wind / 190, 0.3, 1.0) : 0, (t / 3600) * 1.8, 0.16 * D2R, 0); + gl.uniform1f(G.u.uAurora, opts.aurora ?? W.auroraK); + // local detail frame + const sc = opts.scene || W.activeScene(view); + const org = sc ? sc : { lat: view.tLat ?? 0, lon: view.tLon ?? 0, block: 95, rot: 0.3, urban: 0 }; + const enu = geo.enu(org.lat, org.lon), ov = geo.vec(org.lat, org.lon); + const de = v3.sub(eye, ov); + const eyeLocal = [v3.dot(de, enu.east) * EARTH_M, v3.dot(de, enu.north) * EARTH_M, v3.dot(de, enu.up) * EARTH_M]; + gl.uniformMatrix3fv(G.u.uEnu, false, [...enu.east, ...enu.north, ...enu.up]); + gl.uniform3fv(G.u.uEyeLocal, eyeLocal); + const detail = smooth(0.012, 0.0025, range) * (opts.detail ?? 1); + gl.uniform4f(G.u.uCity, detail, org.block || 95, org.rot || 0, org.urban || 0); + gl.uniform1f(G.u.uRange, range); + gl.uniform1i(G.u.uSteps, opts.steps || W.quality.steps); + gl.uniform1f(G.u.uInside, eyeR < 1.0165 ? 1 : 0); + const fo = opts.focus; if (fo) { const fv = geo.vec(fo.lat, fo.lon); gl.uniform4f(G.u.uFocus, fv[0], fv[1], fv[2], fo.k ?? 0.8); } else gl.uniform4f(G.u.uFocus, 0, 0, 1, 0); + GL.bindTex(G, 'uDay', W.tex.day, 0); GL.bindTex(G, 'uNight', W.tex.night, 1); GL.bindTex(G, 'uClouds', W.tex.clouds, 2); GL.bindTex(G, 'uWater', W.tex.water, 3); GL.bindTex(G, 'uTopo', W.tex.topo, 4); + gl.uniform4fv(G.u.uLocalWater, sc && sc.water ? sc.water : [0, 0, 0, 0]); + gl.uniform1f(G.u.uTraffic, W.on('traffic') ? 1 : 0); + GL.fsq(G); + // ---- meshes + gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); + if (sc && sc.mesh && range < 0.03) GE.scenes.draw(sc, view, { sun, t, eye, opts }); + // ---- lines & sprites (additive) + gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE); + const altX = lerp(1, 16, smooth(0.008, 1.3, range)); W.altX = altX; + const sizeK = opts.sizeK ?? clamp(lerp(0.5, 1.1, smooth(2.2, 0.05, range)), 0.4, 1.3); W.sizeK = sizeK; W.lastRange = range; + const px = (opts.pxScale ?? 1) * (chain.w / view.w); + const occR = 1 - clamp(alt * 0.004, 2e-7, 0.0015); + const common = (P, alphaK = 1) => { + gl.useProgram(P.p); + gl.uniformMatrix4fv(P.u.uVP, false, view.VP); gl.uniform3fv(P.u.uEyeH, eyeH); gl.uniform3fv(P.u.uEyeL, eyeL); + gl.uniform1f(P.u.uEyeC, eyeC); gl.uniform1f(P.u.uOccR, occR); gl.uniform1f(P.u.uT, t); + if (P.u.uPx) gl.uniform1f(P.u.uPx, px); if (P.u.uAltX) gl.uniform1f(P.u.uAltX, altX); if (P.u.uSizeK) gl.uniform1f(P.u.uSizeK, sizeK); + if (P.u.uGmst) gl.uniform1f(P.u.uGmst, sun.gmst); if (P.u.uView) gl.uniform2f(P.u.uView, view.w, view.h); if (P.u.uSun) gl.uniform3fv(P.u.uSun, sunDir); + if (P.u.uMinSize) gl.uniform1f(P.u.uMinSize, 1.3); if (P.u.uGlyphPx) gl.uniform1f(P.u.uGlyphPx, range > 0.55 ? 9999 : 6 * px); if (P.u.uAlphaK) gl.uniform1f(P.u.uAlphaK, alphaK); if (P.u.uPass) gl.uniform1f(P.u.uPass, 0); + }; + const lineK = opts.lineK ?? 1; + const LP = PR.line; common(LP, lineK); gl.uniform1f(LP.u.uDashK, 1 / Math.max(range * 0.012, 2e-6)); + for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99) && !(id === 'ais' && range > 1.0)) for (const s of W.static[id]) if (s.kind === 'line') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); } + if (W.dynLineVao && W.dynLineCount) { gl.bindVertexArray(W.dynLineVao); gl.drawArrays(gl.LINES, 0, W.dynLineCount); } + if (opts.extraLines) for (const s of opts.extraLines) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); } + // trails + const kindMask = [W.on('flights') ? 1 : 0, W.on('military') ? 1 : 0, 0, W.on('ga') ? 1 : 0]; + const trailLen = range < 0.02 ? 240 : range < 0.2 ? 900 : 1500; + if ((kindMask[0] || kindMask[1] || kindMask[3]) && opts.trails !== false) { + const TP = PR.trail; common(TP, (range > 1.4 ? 0.22 : 0.4) * lineK); gl.uniform4fv(TP.u.uKindMask, kindMask); gl.uniform1f(TP.u.uTrail, 1); gl.uniform1f(TP.u.uTrailLen, trailLen); gl.uniform1f(TP.u.uSelId, W.selFlight ?? -1); + gl.uniform4f(TP.u.uKindSize, 9, 10, 10, 7); + gl.bindVertexArray(W.trailVao); gl.drawArraysInstanced(gl.LINE_STRIP, 0, W.trailK, W.nFlights); + } + // static sprite sets + const SP = PR.sprite; common(SP); + for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'add') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); } + if (W.dynVao && W.dynCount) { gl.bindVertexArray(W.dynVao); gl.drawArrays(gl.POINTS, 0, W.dynCount); } + if (opts.extraSprites) for (const s of opts.extraSprites) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); } + // flights + if (kindMask[0] || kindMask[1] || kindMask[3]) { + const FP = PR.flight; common(FP); gl.uniform4fv(FP.u.uKindMask, kindMask); gl.uniform1f(FP.u.uTrail, 0); gl.uniform1f(FP.u.uTrailLen, 0); gl.uniform1f(FP.u.uSelId, W.selFlight ?? -1); + gl.uniform4f(FP.u.uKindSize, 13, 15, 14, 10); + gl.bindVertexArray(W.flightVao); gl.drawArraysInstanced(gl.POINTS, 0, 1, W.nFlights); + } + // satellites + const sm0 = [W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0], sm1 = [W.on('debris') ? 1 : 0, W.on('trains') ? 1 : 0, W.on('crewed') ? 1 : 0, 0]; + if (sm0[0] || sm1[0] || sm1[1] || sm1[2]) { + const AP2 = PR.sat; common(AP2, opts.satK ?? 1); gl.uniform4fv(AP2.u.uSatMask0, sm0); gl.uniform4fv(AP2.u.uSatMask1, sm1); gl.uniform1f(AP2.u.uSelId, W.selSat ?? -1); + gl.bindVertexArray(W.satVao); gl.drawArrays(gl.POINTS, 0, W.nSats); + } + // alpha pass (smoke, AQ, radar, GNSS, cone) + gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE); + common(SP); gl.uniform1f(SP.u.uPass, 1); + for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'alpha') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); } + if (W.alphaVao && W.alphaCount) { gl.bindVertexArray(W.alphaVao); gl.drawArrays(gl.POINTS, 0, W.alphaCount); } + gl.disable(gl.BLEND); gl.disable(gl.DEPTH_TEST); gl.depthMask(true); + gl.bindVertexArray(null); + // ---- post + W.post(chain, outFb, opts.outW ?? view.w, opts.outH ?? view.h, mode, opts); +}; +W.post = function (chain, outFb, outW, outH, mode, opts) { + const gl = GL.gl, PR = GL.progs, lv = chain.lv; + const bloomOn = (opts.bloom ?? W.quality.bloom) && mode !== 1; + if (bloomOn) { + const B = PR.bright; gl.useProgram(B.p); + gl.bindFramebuffer(gl.FRAMEBUFFER, lv[0].a.fb); gl.viewport(0, 0, lv[0].a.w, lv[0].a.h); + GL.bindTex(B, 'uSrc', chain.scene.tex, 0); gl.uniform2f(B.u.uTexel, 0.5 / chain.w, 0.5 / chain.h); gl.uniform1f(B.u.uThr, mode === 4 ? 1.2 : 3.2); GL.fsq(B); + const Dn = PR.down; + for (let i = 1; i < 4; i++) { gl.useProgram(Dn.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].a.fb); gl.viewport(0, 0, lv[i].a.w, lv[i].a.h); GL.bindTex(Dn, 'uSrc', lv[i - 1].a.tex, 0); gl.uniform2f(Dn.u.uTexel, 1 / lv[i - 1].a.w, 1 / lv[i - 1].a.h); GL.fsq(Dn); } + const Up = PR.up; + for (let i = 2; i >= 0; i--) { gl.useProgram(Up.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].b.fb); gl.viewport(0, 0, lv[i].b.w, lv[i].b.h); const src = i === 2 ? lv[3].a : lv[i + 1].b; GL.bindTex(Up, 'uSrc', src.tex, 0); GL.bindTex(Up, 'uBase', lv[i].a.tex, 1); gl.uniform2f(Up.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(Up.u.uMix, 1.0); GL.fsq(Up); } + } + const Cp = PR.comp; gl.useProgram(Cp.p); + gl.bindFramebuffer(gl.FRAMEBUFFER, outFb); + gl.viewport(0, 0, opts.vpW ?? (outFb ? outW : gl.drawingBufferWidth), opts.vpH ?? (outFb ? outH : gl.drawingBufferHeight)); + GL.bindTex(Cp, 'uScene', chain.scene.tex, 0); GL.bindTex(Cp, 'uBloom', bloomOn ? lv[0].b.tex : lv[0].a.tex, 1); + gl.uniform2f(Cp.u.uTexel, 1 / chain.w, 1 / chain.h); + gl.uniform1f(Cp.u.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : 0.62)); + gl.uniform1f(Cp.u.uBloomK, bloomOn ? (mode === 4 ? 0.3 : 0.16) : 0); + gl.uniform1f(Cp.u.uVig, opts.vig ?? (mode === 1 ? 0.25 : 0.9)); + gl.uniform1f(Cp.u.uGrain, (opts.grain ?? W.quality.grain) && mode !== 1 ? 0.022 : 0); + gl.uniform1f(Cp.u.uT, performance.now() / 1000 % 100); + gl.uniform1f(Cp.u.uFade, opts.fade ?? 1); + gl.uniform1f(Cp.u.uFxaa, 1); + gl.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.08); + gl.uniform1i(Cp.u.uMode, mode); + gl.uniform3fv(Cp.u.uLift, [0.00017, 0.00038, 0.001]); + gl.uniform3fv(Cp.u.uGain, mode === 0 ? [1.02, 1.0, 0.97] : [1, 1, 1]); + GL.fsq(Cp); +}; + +/* ---------- active local scene (city / terrain) ---------- */ +W.activeScene = function (view) { + if (!GE.scenes) return null; + const range = view.range ?? 1; if (range > 0.03) return null; + const [la, lo] = view.tLat != null ? [view.tLat, view.tLon] : geo.ll(view.eye); + let best = null, bd = 1e9; + for (const s of GE.scenes.list) { const d = geo.dist(la, lo, s.lat, s.lon) * EARTH_KM; if (d < s.radiusKm && d < bd) { bd = d; best = s; } } + if (best) GE.scenes.ensure(best); + return best; +}; + +/* ---------- picking (CPU) ---------- */ +W.pick = function (view, x, y, radius = 26) { + const cands = []; + const consider = (p, item) => { const s = W.project(view, p); if (!s || !s.vis) return; const d = Math.hypot(s.x - x, s.y - y); if (d < radius) cands.push({ d: d - (item.prio || 0), ...item }); }; + const t = W.t; + for (const p of W.situationPins) consider(geo.vec(p.lat, p.lon, 0.2), { kind: 'situation', id: p.id, title: p.title, prio: 10, lat: p.lat, lon: p.lon }); + if (W.on('flights') || W.on('military') || W.on('ga')) GE.FLIGHTS.forEach((f, i) => { + if ((f.kind === 0 && !W.on('flights')) || (f.kind === 1 && !W.on('military')) || (f.kind === 3 && !W.on('ga'))) return; + const p = flightAt(f, t); if (!p) return; + consider(v3.scale(p.v, 1 + (p.altKm * W.altX) / EARTH_KM), { kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog }, prio: 2 }); + }); + if (W.on('helis')) HELI_CIRCUITS.forEach((hc, i) => { const p = heliAt(hc, t); consider(p.v, { kind: 'heli', id: i, title: hc.cs, subtitle: `${hc.type} · ${hc.field || 'orbit'} · ${Math.round(p.altKm * 3281)} ft`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, prio: 4 }); }); + if (W.on('ais') && W._shipCache) GE.SHIPS.forEach((s, i) => { const p = W._shipCache[i]; if (!p) return; consider(geo.vec(p.lat, p.lon, 0.03), { kind: 'ship', id: i, title: s.name, subtitle: `${s.kind} · ${p.sog ? p.sog.toFixed(1) + ' kn' : 'at anchor'} · MMSI ${s.mmsi}`, lat: p.lat, lon: p.lon, hdg: p.hdg }); }); + if (W.on('crewed')) [GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo] = geo.ll(p); consider(p, { kind: 'sat', id: GE.SATS.indexOf(s), title: s.name, subtitle: `${Math.round(s.alt)} km · ${(Math.sqrt(398600.4418 / (6371 + s.alt))).toFixed(2)} km/s`, lat: la, lon: lo, prio: 6 }); }); + if (W.on('quakes')) GE.QUAKES.forEach((q, i) => { if (q.t > t) return; consider(geo.vec(q.lat, q.lon, 0.5), { kind: 'quake', id: i, title: `M ${q.mag.toFixed(1)}`, subtitle: `${q.depth} km deep · ${fmt.ago(-q.t * 1000 + W.offsetH * 3600000)}`, lat: q.lat, lon: q.lon, prio: q.mag }); }); + if (W.on('holo') || W.on('signed')) HOLOS.forEach((hh, i) => consider(geo.vec(hh.lat, hh.lon, 0.2), { kind: 'holo', id: i, title: hh.title, subtitle: `Signed capture · @${hh.by} · illustrative`, lat: hh.lat, lon: hh.lon, prio: 5 })); + cands.sort((a, b) => a.d - b.d); + return cands[0] || null; +}; +W.contactsNear = function (lat, lon, km) { + const out = [], t = W.t; + GE.FLIGHTS.forEach((f, i) => { const p = flightAt(f, t); if (!p) return; const d = geo.dist(lat, lon, p.lat, p.lon) * EARTH_KM; if (d < km) out.push({ kind: 'flight', id: i, title: f.cs, sub: f.type, lat: p.lat, lon: p.lon, altM: p.altKm * 1000, headingDeg: p.hdg * R2D, d }); }); + [GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo, al] = geo.ll(p); const d = geo.dist(lat, lon, la, lo) * EARTH_KM; if (d < 2200) out.push({ kind: 'sat', title: s.name, sub: Math.round(al) + ' km', lat: la, lon: lo, altM: al * 1000, d }); }); + return out.sort((a, b) => a.d - b.d); +}; +W.auroraK = 0.85; +Object.assign(W, { heliAt, holdAt, droneAt, runnerAt, HOLDERS, RUN_V }); diff --git a/mobile/vision/src/js/35-scenes.js b/mobile/vision/src/js/35-scenes.js new file mode 100644 index 000000000..e74a232b3 --- /dev/null +++ b/mobile/vision/src/js/35-scenes.js @@ -0,0 +1,240 @@ +/* Local 3D scenes for the low-altitude "photoreal city" treatment: procedural extruded city blocks + aligned with the globe shader's street grid, stadium bowl, airfields, launch pad, and a Himalayan + valley heightfield for the Bhote Koshi reconstruction (river carved along the real corridor). */ +const SC = (GE.scenes = {}); +const VSTR = 12; // pos3 nor3 uv2 attr4 +SC.list = [ + { id: 'nyc', name: 'Midtown Manhattan', lat: 40.7549, lon: -73.9840, block: 88, rot: 29 * D2R, urban: 0.55, radiusKm: 16, kind: 'city', R: 3000, maxH: 240, tower: { lat: 40.7553, lon: -73.9838, h: 412, w: 46 } }, + { id: 'chi', name: 'Chicago Loop', lat: 41.8789, lon: -87.6359, block: 100, rot: 0, urban: 0.45, radiusKm: 18, kind: 'city', R: 2600, maxH: 260, water: [0.94, 0.342, 1824, 1], downtown: [41.8807, -87.6293] }, + { id: 'wembley', name: 'Wembley', lat: 51.5560, lon: -0.2796, block: 74, rot: 0.21, urban: 0.25, radiusKm: 10, kind: 'stadium', R: 1500, maxH: 40 }, + { id: 'novosel', name: 'Cairns Army Airfield', lat: 31.2760, lon: -85.7130, block: 140, rot: 0.06, urban: -0.2, radiusKm: 28, kind: 'airfield', R: 0 }, + { id: 'koshi', name: 'Bhote Koshi corridor', lat: 28.2150, lon: 85.3450, block: 100, rot: 0, urban: -1, radiusKm: 32, kind: 'terrain', R: 0 }, + { id: 'cape', name: 'SLC-40', lat: 28.5618, lon: -80.5772, block: 120, rot: 0, urban: -0.5, radiusKm: 14, kind: 'pad', R: 0 }, + { id: 'drone', name: 'Drone corridor', lat: 30.5880, lon: -96.2900, block: 62, rot: 0.12, urban: 0.3, radiusKm: 12, kind: 'suburb', R: 2200, maxH: 9 }, + { id: 'atl', name: 'ATL', lat: 33.6407, lon: -84.4277, block: 120, rot: 0, urban: 0.0, radiusKm: 10, kind: 'airport', R: 0 }, +]; +SC.byId = Object.fromEntries(SC.list.map((s) => [s.id, s])); +// Bhote Koshi corridor (approximate public geography): Rasuwagadhi → Timure → Syaphrubesi → Trishuli +const KOSHI = [[28.2860, 85.3815], [28.2802, 85.3797], [28.2700, 85.3752], [28.2555, 85.3705], [28.2400, 85.3650], [28.2250, 85.3600], [28.2100, 85.3550], [28.1950, 85.3500], [28.1800, 85.3460], [28.1660, 85.3420], [28.1600, 85.3380], [28.1500, 85.3230], [28.1400, 85.3060], [28.1300, 85.2900]]; +SC.KOSHI = KOSHI; +SC.FLOOD = { startMs: Date.UTC(2026, 7, 26, 7, 45), speedMH: 18000, crest: 9 }; + +function Mesh() { this.v = []; this.i = []; } +Mesh.prototype.vert = function (p, n, uv, at) { this.v.push(p[0], p[1], p[2], n[0], n[1], n[2], uv[0], uv[1], at[0], at[1], at[2], at[3]); return this.v.length / VSTR - 1; }; +Mesh.prototype.quad = function (a, b, c, d, n, uvs, at) { const i0 = this.vert(a, n, uvs[0], at), i1 = this.vert(b, n, uvs[1], at), i2 = this.vert(c, n, uvs[2], at), i3 = this.vert(d, n, uvs[3], at); this.i.push(i0, i1, i2, i0, i2, i3); }; +// oriented box: center (x,y) in ENU m, size w×d, height h from z0, rotation rot +Mesh.prototype.box = function (x, y, w, d, h, rot, at, z0 = 0) { + if (at[0] === 4) at = [4, at[1], at[2], d]; + const c = Math.cos(rot), s = Math.sin(rot); + const P = (u, v) => [x + u * c - v * s, y + u * s + v * c]; + const pts = [P(-w / 2, -d / 2), P(w / 2, -d / 2), P(w / 2, d / 2), P(-w / 2, d / 2)]; + const z1 = z0 + h; + for (let k = 0; k < 4; k++) { + const a = pts[k], b = pts[(k + 1) % 4]; + const ex = b[0] - a[0], ey = b[1] - a[1], L = Math.hypot(ex, ey); + const n = [ey / L, -ex / L, 0]; + this.quad([a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1], n, [[0, 0], [L, 0], [L, h], [0, h]], at); + } + this.quad([pts[0][0], pts[0][1], z1], [pts[1][0], pts[1][1], z1], [pts[2][0], pts[2][1], z1], [pts[3][0], pts[3][1], z1], [0, 0, 1], [[0, 0], [w, 0], [w, d], [0, d]], at); +}; + +const enuOf = (sc, lat, lon) => { const e = geo.enu(sc.lat, sc.lon); const d = v3.sub(geo.vec(lat, lon), geo.vec(sc.lat, sc.lon)); return [v3.dot(d, e.east) * EARTH_M, v3.dot(d, e.north) * EARTH_M]; }; +SC.enuOf = enuOf; +const llOf = (sc, x, y) => geo.offset(sc.lat, sc.lon, x, y); + +function genCity(sc, M, R0) { + const B = sc.block, c = Math.cos(sc.rot), s = Math.sin(sc.rot); + const rr = rng(sc.id.length * 991 + 7); + const R = sc.R, n = Math.ceil(R / B) + 1; + const dt = sc.downtown ? enuOf(sc, sc.downtown[0], sc.downtown[1]) : [0, 0]; + const twr = sc.tower ? enuOf(sc, sc.tower.lat, sc.tower.lon) : null; + const hsh = (i, j) => hash01(i * 157.31 + j * 311.7 + sc.id.length); + for (let i = -n; i < n; i++) for (let j = -n; j < n; j++) { + const pcx = (i + 0.5) * B, pcy = (j + 0.5) * B; + const qx = c * pcx - s * pcy, qy = s * pcx + c * pcy; + const rd = Math.hypot(qx, qy); if (rd > R) continue; + if (sc.water && qx * sc.water[0] + qy * sc.water[1] > sc.water[2] - 40) continue; + if (sc.kind === 'stadium' && Math.hypot(qx, qy) < 280) continue; + if (twr && Math.hypot(qx - twr[0], qy - twr[1]) < 40) continue; + const [la, lo] = llOf(sc, qx, qy); + if (GE.world.waterAt(la, lo) > 0.6 && !sc.water) continue; + const dens = clamp(GE.world.lightsAt(la, lo) * 0.9 + sc.urban, 0, 1) * smooth(R, R * 0.6, rd); + if (hsh(i, j) > dens * 1.25 + 0.05) continue; + const rw = (0.085 + ((((i % 5) + 5) % 5) === 4 ? 0.05 : 0)) * B + 3; + const inner = B - 2 * rw; + const kx = inner > 50 && rr() < 0.6 ? 2 : 1, ky = inner > 50 && rr() < 0.6 ? 2 : 1; + const ddt = Math.hypot(qx - dt[0], qy - dt[1]); + for (let a = 0; a < kx; a++) for (let b = 0; b < ky; b++) { + const lw = inner / kx, ld = inner / ky; + const lpx = i * B + rw + lw * (a + 0.5), lpy = j * B + rw + ld * (b + 0.5); + const x = c * lpx - s * lpy, y = s * lpx + c * lpy; + const hh = hash01(lpx * 0.37 + lpy * 0.71); + const core = Math.exp(-ddt / (sc.R * 0.28)); + let h = sc.kind === 'suburb' ? 5 + hh * 4 : 9 + Math.pow(dens, 1.6) * (sc.maxH * (0.15 + 0.85 * core)) * Math.exp(rr.gauss() * 0.45); + h = clamp(h, 6, sc.maxH * 1.4); + const inset = 2 + hh * 3; + const type = sc.kind === 'suburb' ? 6 : 0; + M.box(x, y, lw - inset * 2, ld - inset * 2, h, sc.rot, [type, hh, h, 0]); + if (type === 0 && h > 60 && hh > 0.5) M.box(x, y, (lw - inset * 2) * 0.6, (ld - inset * 2) * 0.6, h * 0.25, sc.rot, [0, hh, h * 1.25, 0], h); + } + } + if (twr) { // the fictional tower (skyscraper climb reconstruction) + const T = sc.tower; M.box(twr[0], twr[1], T.w, T.w, T.h * 0.62, sc.rot, [0, 0.8, T.h, 0]); M.box(twr[0], twr[1], T.w * 0.72, T.w * 0.72, T.h * 0.3, sc.rot, [0, 0.8, T.h, 0], T.h * 0.62); M.box(twr[0], twr[1], 3, 3, T.h * 0.08, sc.rot, [5, 0.5, T.h, 0], T.h * 0.92); + } +} +function genStadium(sc, M) { + const N = 72; const rings = [[64, 46, 4], [92, 74, 20], [128, 108, 44]]; + for (let k = 0; k < N; k++) { + const a0 = (k / N) * TAU, a1 = ((k + 1) / N) * TAU; + for (let r = 0; r < rings.length - 1; r++) { + const [ax, ay, az] = rings[r], [bx, by, bz] = rings[r + 1]; + const p = (A, ex, ey, ez) => [Math.cos(A) * ex, Math.sin(A) * ey, ez]; + const p0 = p(a0, ax, ay, az), p1 = p(a1, ax, ay, az), p2 = p(a1, bx, by, bz), p3 = p(a0, bx, by, bz); + const e1 = v3.sub(p1, p0), e2 = v3.sub(p3, p0); const n = v3.norm(v3.cross(e1, e2)); + M.quad(p0, p1, p2, p3, n[2] < 0 ? v3.scale(n, -1) : n, [[k * 4, r * 20], [k * 4 + 4, r * 20], [k * 4 + 4, r * 20 + 20], [k * 4, r * 20 + 20]], [3, 0.5, 40, 0]); + } + // outer facade + const [ox, oy] = [134, 114]; const o0 = [Math.cos(a0) * ox, Math.sin(a0) * oy], o1 = [Math.cos(a1) * ox, Math.sin(a1) * oy]; + const L = Math.hypot(o1[0] - o0[0], o1[1] - o0[1]); const nn = v3.norm([o0[0] + o1[0], o0[1] + o1[1], 0]); + M.quad([o0[0], o0[1], 0], [o1[0], o1[1], 0], [o1[0], o1[1], 50], [o0[0], o0[1], 50], nn, [[0, 0], [L, 0], [L, 50], [0, 50]], [0, 0.75, 50, 0]); + // roof ring + const r0 = [Math.cos(a0) * 104, Math.sin(a0) * 86], r1 = [Math.cos(a1) * 104, Math.sin(a1) * 86]; + M.quad([r0[0], r0[1], 52], [r1[0], r1[1], 52], [o1[0] * 1.02, o1[1] * 1.02, 52], [o0[0] * 1.02, o0[1] * 1.02, 52], [0, 0, 1], [[0, 0], [1, 0], [1, 1], [0, 1]], [0, 0.6, 52, 0]); + } + M.quad([-55, -36, 0.6], [55, -36, 0.6], [55, 36, 0.6], [-55, 36, 0.6], [0, 0, 1], [[0, 0], [110, 0], [110, 72], [0, 72]], [4, 1, 0, 0]); + M.box(-40, 0, 14, 30, 13, 0, [5, 0.5, 13, 0], 0.6); + for (let k = 0; k < 6; k++) M.box(-40, -12 + k * 5, 1.2, 1.2, 22, 0, [5, 0.5, 22, 0], 13); +} +function genAirfield(sc, M) { + const rw = (x, y, L, W, rot) => M.box(x, y, L, W, 0.4, rot, [4, 0, 0.4, 0]); + rw(0, 0, 1550, 46, 0.06); rw(150, 100, 1500, 46, 0.06 + 1.1); rw(-300, -420, 900, 30, 0.06 + Math.PI / 2); + for (let i = 0; i < 26; i++) { const x = -600 + (i % 13) * 95, y = 260 + Math.floor(i / 13) * 70; M.box(x, y, 28, 28, 0.35, 0.06, [4, 0, 0.3, 0]); } + for (let i = 0; i < 6; i++) M.box(-500 + i * 120, 420, 80, 48, 14, 0.06, [0, 0.4 + i * 0.05, 14, 0]); + M.box(260, -180, 14, 14, 26, 0.06, [0, 0.9, 26, 0]); + // stagefields + for (const [la, lo] of [[31.36, -85.63], [31.20, -85.81], [31.42, -85.79]]) { const [x, y] = enuOf(sc, la, lo); M.box(x, y, 700, 40, 0.4, 0.4, [4, 0, 0.4, 0]); for (let k = 0; k < 6; k++) M.box(x - 250 + k * 100, y + 80, 26, 26, 0.35, 0.4, [4, 0, 0.3, 0]); } +} +function genPad(sc, M) { + M.box(0, 0, 220, 160, 0.5, 0, [4, 0, 0.5, 0]); + M.box(-14, 0, 12, 12, 88, 0, [0, 0.55, 88, 0]); + M.box(4, 0, 4.2, 4.2, 70, 0, [0, 0.98, 70, 0]); + M.box(4, 0, 4.4, 4.4, 2, 0, [5, 0.5, 2, 0], 70); + for (const [x, y, w, d, h] of [[300, 180, 60, 40, 22], [-260, 220, 90, 30, 12], [400, -260, 30, 30, 14]]) M.box(x, y, w, d, h, 0, [0, 0.4, h, 0]); +} +function genAirport(sc, M) { + const rw = [[-1000, 1200, 3700], [-1100, 950, 2700], [-900, -300, 2700], [-1000, -560, 3000], [-1150, -1200, 2900]]; + for (const [x, y, L] of rw) M.box(x + L / 2 - 1000, y, L, 46, 0.4, 0, [4, 0, 0.4, 0]); + for (let k = 0; k < 7; k++) M.box(-1100 + k * 420, 330, 46, 620, 16, 0, [0, 0.35, 16, 0]); + M.box(-1500, 330, 260, 220, 22, 0, [0, 0.6, 22, 0]); + M.box(1400, 1400, 14, 14, 70, 0, [0, 0.9, 70, 0]); +} +// Bhote Koshi valley heightfield +function koshiRiverXY(sc) { return KOSHI.map(([la, lo]) => enuOf(sc, la, lo)); } +function genTerrain(sc, M) { + const riv = koshiRiverXY(sc); + const cum = [0]; for (let i = 1; i < riv.length; i++) cum.push(cum[i - 1] + Math.hypot(riv[i][0] - riv[i - 1][0], riv[i][1] - riv[i - 1][1])); + sc.riverLen = cum[cum.length - 1]; + const nearest = (x, y) => { let bd = 1e12, bs = 0; for (let i = 1; i < riv.length; i++) { const [ax, ay] = riv[i - 1], [bx, by] = riv[i]; const ex = bx - ax, ey = by - ay; const L2 = ex * ex + ey * ey; const t = clamp(((x - ax) * ex + (y - ay) * ey) / L2, 0, 1); const px = ax + ex * t - x, py = ay + ey * t - y; const d = px * px + py * py; if (d < bd) { bd = d; bs = cum[i - 1] + t * Math.sqrt(L2); } } return [Math.sqrt(bd), bs]; }; + const bed = (s) => 1880 - s * 0.021; + const ridged = (x, y) => { let a = 0.5, f = 1 / 5200, sum = 0; for (let o = 0; o < 6; o++) { const n = 1 - Math.abs(vn2(x * f + o * 13.1, y * f + o * 7.7) * 2 - 1); sum += n * n * a; a *= 0.5; f *= 2.05; } return sum; }; + const height = (x, y) => { + const [d, s] = nearest(x, y); + const base = 1750 + ridged(x, y) * 2900 + y * 0.03 + Math.max(0, x) * 0.035; + const b = bed(s); + const k = Math.pow(clamp(d / 2600, 0, 1), 0.72); + return [b + (base - b) * k + Math.min(d, 60) * 0.05, d, s]; + }; + sc.height = (x, y) => height(x, y)[0]; + const N = 180, HALF = 19000; const step = (2 * HALF) / N; + const H = new Float32Array((N + 1) * (N + 1)), Dd = new Float32Array((N + 1) * (N + 1)), Ss = new Float32Array((N + 1) * (N + 1)); + for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) { const x = -HALF + i * step, y = -HALF + j * step; const [h, d, s] = height(x, y); const k = j * (N + 1) + i; H[k] = h; Dd[k] = d; Ss[k] = s; } + const base = M.v.length / VSTR; + for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) { + const k = j * (N + 1) + i; const x = -HALF + i * step, y = -HALF + j * step; + const hl = H[j * (N + 1) + Math.max(0, i - 1)], hr = H[j * (N + 1) + Math.min(N, i + 1)], hd = H[Math.max(0, j - 1) * (N + 1) + i], hu = H[Math.min(N, j + 1) * (N + 1) + i]; + const n = v3.norm([(hl - hr) / (2 * step), (hd - hu) / (2 * step), 1]); + const edge = Math.min(i, j, N - i, N - j); const z = edge === 0 ? 0 : H[k]; + M.vert([x, y, z], n, [Ss[k], 0], [1, hash01(k), H[k], smooth(260, 40, Dd[k])]); + } + for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const a = base + j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1; M.i.push(a, b, d, a, d, c); } + // river / flood water strip + const wbase = M.v.length / VSTR; const pts = []; + for (let s = 0; s <= sc.riverLen; s += 70) { let i = 1; while (i < cum.length - 1 && cum[i] < s) i++; const f = (s - cum[i - 1]) / (cum[i] - cum[i - 1]); const x = lerp(riv[i - 1][0], riv[i][0], f), y = lerp(riv[i - 1][1], riv[i][1], f); const tx = riv[i][0] - riv[i - 1][0], ty = riv[i][1] - riv[i - 1][1], tl = Math.hypot(tx, ty); pts.push([x, y, -ty / tl, tx / tl, s]); } + for (const [x, y, nx, ny, s] of pts) { const W2 = 210; const z = bed(s) + 4; M.vert([x - nx * W2, y - ny * W2, z], [0, 0, 1], [s, -1], [2, 0, 0, 0]); M.vert([x + nx * W2, y + ny * W2, z], [0, 0, 1], [s, 1], [2, 0, 0, 0]); } + for (let k = 0; k < pts.length - 1; k++) { const a = wbase + k * 2; M.i.push(a, a + 1, a + 3, a, a + 3, a + 2); } + // villages & bridge + const village = (la, lo, n, spread) => { const [cx, cy] = enuOf(sc, la, lo); const r = rng(Math.round(la * 1000)); for (let k = 0; k < n; k++) { const x = cx + r.gauss() * spread, y = cy + r.gauss() * spread; const z = sc.height(x, y); M.box(x, y, 7 + r() * 6, 8 + r() * 6, 5 + r() * 4, r() * 3, [6, r(), 6, 0], z - 1); } }; + village(28.1600, 85.3395, 70, 130); village(28.2560, 85.3690, 22, 80); village(28.2790, 85.3770, 14, 60); village(28.2050, 85.3520, 10, 60); + const [bx, by] = enuOf(sc, 28.2802, 85.3797); M.box(bx, by, 6, 120, 2.5, 0.6, [0, 0.9, 3, 0], bed(sc.riverLen * 0.03) + 18); + const [bx2, by2] = enuOf(sc, 28.1625, 85.3405); M.box(bx2, by2, 5, 110, 2.5, 1.0, [0, 0.9, 3, 0], bed(sc.riverLen * 0.83) + 16); + sc.riverPts = pts; sc.bed = bed; +} +function vn2(x, y) { const i = Math.floor(x), j = Math.floor(y), fx = x - i, fy = y - j; const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); const h = (a, b) => hash01(a * 12.9898 + b * 78.233); return lerp(lerp(h(i, j), h(i + 1, j), u), lerp(h(i, j + 1), h(i + 1, j + 1), u), v); } + +SC.ensure = function (sc) { + if (sc.mesh || sc.building) return; + sc.building = true; + const M = new Mesh(); + if (sc.kind === 'city' || sc.kind === 'suburb' || sc.kind === 'stadium') genCity(sc, M); + if (sc.kind === 'stadium') genStadium(sc, M); + if (sc.kind === 'airfield') genAirfield(sc, M); + if (sc.kind === 'pad') genPad(sc, M); + if (sc.kind === 'airport') genAirport(sc, M); + if (sc.kind === 'terrain') genTerrain(sc, M); + if (sc.kind === 'suburb') M.box(0, 0, 130, 60, 12, sc.rot, [0, 0.3, 12, 0]); + const gl = GL.gl, P = GL.progs.mesh; + const vb = GL.buffer(new Float32Array(M.v)); + const ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(M.i), gl.STATIC_DRAW); + sc.vao = GL.vao(P, { aPos: [vb, 3, VSTR, 0], aNor: [vb, 3, VSTR, 3], aUV: [vb, 2, VSTR, 6], aAttr: [vb, 4, VSTR, 8] }, ib); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); + sc.count = M.i.length; sc.mesh = true; sc.building = false; + sc.enu = geo.enu(sc.lat, sc.lon); sc.origin = geo.vec(sc.lat, sc.lon); +}; + +/* ---------- JS atmosphere (matches the shader) for mesh lighting & fog ---------- */ +const HR = 0.0021, HM = 0.0006, RA = 1.0165, BR = [22.1, 51.5, 126.3], BMk = 42.0; +function odH(r, mu, H) { const c = Math.sqrt(Math.PI * 0.5 * r / H), h = (r - 1) / H; if (mu >= 0) return H * Math.exp(-h) * c / (c * mu + 1); const r0 = r * Math.sqrt(Math.max(0, 1 - mu * mu)); if (r0 < 1) return 1e4; const h0 = (r0 - 1) / H, c0 = Math.sqrt(Math.PI * 0.5 * r0 / H); return H * (2 * Math.exp(-h0) * c0 - Math.exp(-h) * c / (-c * mu + 1)); } +function sunTransJS(x, sun) { const r = v3.len(x), mu = v3.dot(x, sun) / r; const oR = odH(r, mu, HR), oM = odH(r, mu, HM); return BR.map((b) => Math.exp(-(b * oR + BMk * 1.11 * oM))); } +function atmoJS(ro, rd, tEnd, sun, steps = 10) { + const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - RA * RA, d = b * b - c; if (d < 0) return [0, 0, 0]; + const sq = Math.sqrt(d); const t0 = Math.max(-b - sq, 0), t1 = Math.min(-b + sq, tEnd); if (t1 <= t0) return [0, 0, 0]; + const mu = v3.dot(rd, sun), pR = (3 / (16 * Math.PI)) * (1 + mu * mu), g = 0.76, pM = (3 / (8 * Math.PI)) * ((1 - g * g) * (1 + mu * mu)) / ((2 + g * g) * Math.pow(1 + g * g - 2 * g * mu, 1.5)); + let sR = [0, 0, 0], sM = [0, 0, 0], oR = 0, oM = 0; const L = t1 - t0; + for (let i = 0; i < steps; i++) { const fa = i / steps, fb = (i + 1) / steps; const ta = fa * fa, tb = fb * fb; const ds = (tb - ta) * L; const t = t0 + (ta + tb) * 0.5 * L; const x = v3.madd(ro, rd, t); const r = v3.len(x), h = r - 1; const dR = Math.exp(-h / HR), dM = Math.exp(-h / HM); oR += dR * ds; oM += dM * ds; const muS = v3.dot(x, sun) / r; const lR = odH(r, muS, HR), lM = odH(r, muS, HM); for (let k = 0; k < 3; k++) { const att = Math.exp(-(BR[k] * (oR + lR) + BMk * 1.11 * (oM + lM))); sR[k] += dR * att * ds; sM[k] += dM * att * ds; } } + return [0, 1, 2].map((k) => 20 * (sR[k] * BR[k] * pR + sM[k] * BMk * pM)); +} +SC.atmoJS = atmoJS; SC.sunTransJS = sunTransJS; + +SC.draw = function (sc, view, { sun, t, eye, opts }) { + const gl = GL.gl, P = GL.progs.mesh; if (!sc.vao) return; + const e = sc.enu, o = sc.origin, R = EARTH_M; + const d = v3.sub(o, eye); + const M = [e.east[0] / R, e.east[1] / R, e.east[2] / R, 0, e.north[0] / R, e.north[1] / R, e.north[2] / R, 0, e.up[0] / R, e.up[1] / R, e.up[2] / R, 0, d[0], d[1], d[2], 1]; + const MVP = m4.mul(view.VP, M); + gl.useProgram(P.p); + gl.uniformMatrix4fv(P.u.uMVP, false, MVP); + const sd = sun.dir; const sunL = [v3.dot(sd, e.east), v3.dot(sd, e.north), v3.dot(sd, e.up)]; + gl.uniform3fv(P.u.uSunL, sunL); + const tr = sunTransJS(v3.scale(o, 1.0002), sd); + const k = 20 / Math.PI; const night = opts.nightForce ? 1 : 1 - smooth(-0.14, 0.06, sunL[2]); + const clear = (opts.lens || GE.world.lens) === 'clear'; + gl.uniform3fv(P.u.uSunCol, clear ? [2.2, 2.2, 2.2] : tr.map((x) => x * k * (opts.nightForce ? 0.03 : 1))); + const amb = smooth(-0.25, 0.5, sunL[2]); + gl.uniform3fv(P.u.uSkyCol, clear ? [1.2, 1.25, 1.35] : [0.03 * 20 / Math.PI * 1.6 * amb + 0.014, 0.045 * 20 / Math.PI * 1.6 * amb + 0.02, 0.085 * 20 / Math.PI * 1.6 * amb + 0.04]); + // fog colour: inscatter along the horizon in the camera's look direction + const fwd = v3.scale(view.back, -1); const fh = v3.norm(v3.madd(fwd, e.up, -v3.dot(fwd, e.up))); const ro = v3.madd(o, e.up, 0.0003); + let fog = atmoJS(ro, fh, 0.012, sd, 8); fog = fog.map((x) => x * 0.8 + 0.0015); + gl.uniform3fv(P.u.uFogCol, fog); + const el = [v3.dot(v3.scale(d, -1), e.east) * R, v3.dot(v3.scale(d, -1), e.north) * R, v3.dot(v3.scale(d, -1), e.up) * R]; + gl.uniform3fv(P.u.uEyeL, el); + gl.uniform1f(P.u.uNight, night); gl.uniform1f(P.u.uT, t); + gl.uniform1f(P.u.uVis, sc.kind === 'terrain' ? 110000 : 26000); + const fl = SC.FLOOD; const simMs = opts.simMs ?? GE.world.simMs; + gl.uniform4f(P.u.uFlood, (simMs - fl.startMs) / 3600000, 0, fl.speedMH, fl.crest); + gl.uniform1f(P.u.uClear, clear ? 1 : 0); + gl.bindVertexArray(sc.vao); + gl.drawElements(gl.TRIANGLES, sc.count, gl.UNSIGNED_INT, 0); + gl.bindVertexArray(null); +}; +// ground height (m) at lat/lon for scenes with terrain +SC.groundAt = function (lat, lon) { const sc = SC.byId.koshi; if (!sc.height) return 0; const [x, y] = enuOf(sc, lat, lon); return sc.height(x, y); }; diff --git a/mobile/vision/src/js/40-camera.js b/mobile/vision/src/js/40-camera.js new file mode 100644 index 000000000..330d3bfa1 --- /dev/null +++ b/mobile/vision/src/js/40-camera.js @@ -0,0 +1,189 @@ +/* Camera: orbit (target lat/lon, range, heading, tilt, lens shift) with touch/pinch/wheel and + inertia; fly-to along eased great-circle arcs with a log-space altitude hump (van Wijk-style); + free rigs (ride / look / narrative) with smooth hand-off. */ +const Cam = (GE.cam = { + lat: 18, lon: 40, range: 2.7, heading: 0, tilt: 0, shiftX: 0, shiftY: 0, fovMin: 40 * D2R, + anim: null, vel: null, rig: null, rigBlend: 0, rigFrom: null, minRange: 0.00007, maxRange: 6, + interacting: false, lastInput: 0, enabled: true, +}); +Cam.maxTilt = (r) => lerp(0.0, 1.32, smooth(1.4, 0.004, r)); +Cam.basis = function () { + const T = geo.vec(Cam.lat, Cam.lon, Cam.targetAltKm || 0); + const { east, north, up } = geo.enu(Cam.lat, Cam.lon); + const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading); + const F = v3.add(v3.scale(north, ch), v3.scale(east, sh)); + const tilt = Math.min(Cam.tilt, Cam.maxTilt(Cam.range)); + const off = v3.norm(v3.add(v3.scale(up, Math.cos(tilt)), v3.scale(F, -Math.sin(tilt)))); + const eye = v3.madd(T, off, Cam.range); + const upv = v3.norm(v3.add(v3.scale(F, Math.cos(tilt)), v3.scale(up, Math.sin(tilt)))); + const back = off; const right = v3.norm(v3.cross(upv, back)); + return { eye, right, up: v3.cross(back, right), back, T }; +}; +function fovFor(w, h, fovMin) { const aspect = w / h; return aspect < 1 ? 2 * Math.atan(Math.tan(fovMin / 2) / aspect) : fovMin; } +Cam.view = function (w, h) { + let b = Cam.basis(); let fovMin = Cam.fovMin; let range = Cam.range; + if (Cam.rig) { + const r = Cam.rig.pose(); + const k = ease.inOut(clamp(Cam.rigBlend, 0, 1)); + const from = Cam.rigFrom || b; + const eye = v3.lerp(from.eye, r.eye, k); + const back = v3.norm(v3.lerp(from.back, r.back, k)); let up = v3.norm(v3.lerp(from.up, r.up, k)); + const right = v3.norm(v3.cross(up, back)); up = v3.cross(back, right); + b = { eye, right, up, back }; + fovMin = lerp(Cam.fovMin, r.fov || Cam.fovMin, k); + range = lerp(Cam.range, v3.len(eye) - 1, k); + } + const aspect = w / h, fovY = fovFor(w, h, fovMin); + const alt = Math.max(v3.len(b.eye) - 1, 1e-7); + const horizon = Math.sqrt(2 * alt + alt * alt); + const near = clamp(alt * 0.03, 1.2e-8, 0.3); + const far = Math.max(near * 20, Math.min(12, horizon * 1.3 + 0.004)); + const [tLat, tLon] = Cam.rig ? geo.ll(b.eye) : [Cam.lat, Cam.lon]; + return { eye: b.eye, right: b.right, up: b.up, back: b.back, fovY, aspect, near, far, shiftX: Cam.shiftX, shiftY: Cam.shiftY, w, h, range, tLat, tLon }; +}; +Cam.ray = function (view, x, y) { + const nx = (x / view.w) * 2 - 1, ny = 1 - (y / view.h) * 2; + const tY = Math.tan(view.fovY / 2), tX = tY * view.aspect; + return v3.norm(v3.add(v3.add(v3.scale(view.right, (nx - view.shiftX) * tX), v3.scale(view.up, (ny - view.shiftY) * tY)), v3.scale(view.back, -1))); +}; +Cam.groundAt = function (view, x, y) { + const rd = Cam.ray(view, x, y), ro = view.eye; const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - 1, d = b * b - c; + if (d < 0 || b > 0) return null; const t = c / (-b + Math.sqrt(d)); return geo.ll(v3.madd(ro, rd, t)); +}; +Cam.snapshot = () => ({ lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftY: Cam.shiftY, shiftX: Cam.shiftX }); +Cam.set = (s) => { Object.assign(Cam, s); Cam.anim = null; Cam.vel = null; }; + +/* ---------- fly-to ---------- */ +const angDiff = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; }; +Cam.flyTo = function (to, opts = {}) { + const from = Cam.snapshot(); + const tgt = { ...from, ...to }; + const dist = geo.dist(from.lat, from.lon, tgt.lat, tgt.lon); + let dur = opts.duration ?? clamp(1.1 + Math.abs(Math.log(tgt.range / from.range)) * 0.22 + dist * 0.55, 1.0, 3.4); + if (prefersReduced() || opts.instant) dur = 0; + const peak = Math.max(from.range, tgt.range, dist * 1.25); + Cam.vel = null; + return new Promise((res) => { + if (dur === 0) { Cam.set(tgt); res(); return; } + Cam.anim = { from, to: tgt, t: 0, dur, peak, hump: peak > Math.max(from.range, tgt.range) * 1.05, res, va: geo.vec(from.lat, from.lon), vb: geo.vec(tgt.lat, tgt.lon), dh: angDiff(from.heading, tgt.heading) }; + }); +}; +Cam.cancel = () => { if (Cam.anim) { const r = Cam.anim.res; Cam.anim = null; r && r(); } }; +Cam.update = function (dt) { + const A = Cam.anim; + if (A) { + A.t += dt; const u = clamp(A.t / A.dur, 0, 1); const e = ease.inOut(u); + const pe = A.hump ? ease.inOutSine(clamp((u - 0.08) / 0.84, 0, 1)) : e; + const p = geo.slerp(A.va, A.vb, pe); const [la, lo] = geo.ll(p); + Cam.lat = la; Cam.lon = lo; + const l0 = Math.log(A.from.range), l1 = Math.log(A.to.range), lm = Math.log(A.peak); + Cam.range = A.hump ? Math.exp((1 - e) * (1 - e) * l0 + 2 * (1 - e) * e * lm + e * e * l1) : Math.exp(lerp(l0, l1, e)); + Cam.heading = A.from.heading + A.dh * e; + const tiltMid = A.hump ? Math.sin(Math.PI * e) : 0; + Cam.tilt = lerp(A.from.tilt, A.to.tilt, e) * (1 - tiltMid * 0.85); + Cam.shiftY = lerp(A.from.shiftY, A.to.shiftY, e); Cam.shiftX = lerp(A.from.shiftX, A.to.shiftX, e); + if (u >= 1) { Cam.anim = null; Object.assign(Cam, { heading: A.to.heading, tilt: A.to.tilt, range: A.to.range, lat: A.to.lat, lon: A.to.lon }); A.res(); } + return true; + } + if (Cam.rig) { Cam.rigBlend = Math.min(1, Cam.rigBlend + dt / (Cam.rig.blendTime || 1.4)); return true; } + if (Cam.vel) { + const v = Cam.vel; const k = Math.exp(-dt * 3.2); + Cam.lat = clamp(Cam.lat + v.lat * dt, -86, 86); Cam.lon = ((Cam.lon + v.lon * dt + 540) % 360) - 180; Cam.heading += v.h * dt; + v.lat *= k; v.lon *= k; v.h *= k; + if (Math.abs(v.lat) + Math.abs(v.lon) + Math.abs(v.h) < 1e-4) Cam.vel = null; + return true; + } + if (Cam.autoSpin && !Cam.interacting && performance.now() - Cam.lastInput > 2500) { Cam.lon = ((Cam.lon + dt * Cam.autoSpin + 540) % 360) - 180; return true; } + return false; +}; +Cam.setRig = function (rig) { // rig.pose() → {eye, back, up, fov} + Cam.rigFrom = rig ? (Cam.rig ? Cam.view(400, 800) : Cam.basis()) : null; + if (rig) { Cam.anim = null; Cam.vel = null; } + Cam.rig = rig; Cam.rigBlend = prefersReduced() ? 1 : 0; +}; +// pose helper: eye at lat/lon/alt, looking along heading/pitch (rad), optional roll +Cam.pose = function (lat, lon, altKm, hdg, pitch, roll = 0, fov) { + const eye = geo.vec(lat, lon, altKm); const { east, north, up } = geo.enu(lat, lon); + const F0 = v3.add(v3.scale(north, Math.cos(hdg)), v3.scale(east, Math.sin(hdg))); + const fwd = v3.norm(v3.add(v3.scale(F0, Math.cos(pitch)), v3.scale(up, Math.sin(pitch)))); + let upv = v3.norm(v3.sub(up, v3.scale(fwd, v3.dot(up, fwd)))); + let right = v3.norm(v3.cross(fwd, upv)); + if (roll) { const c = Math.cos(roll), s = Math.sin(roll); const u2 = v3.add(v3.scale(upv, c), v3.scale(right, s)); right = v3.norm(v3.cross(fwd, u2)); upv = u2; } + return { eye, back: v3.scale(fwd, -1), up: upv, fov }; +}; + +/* ---------- input ---------- */ +Cam.attach = function (el, getView) { + const pts = new Map(); let last = null, moved = 0, downAt = 0, samples = []; + const metersPx = (view) => (Cam.range * 2 * Math.tan(view.fovY / 2)) / view.h * (1 + Cam.range * 0.18); + const panBy = (dx, dy, view) => { + const k = metersPx(view); + const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading); + const tiltK = 1 + Math.min(Cam.tilt, Cam.maxTilt(Cam.range)) * 0.8; + const de = (-dx * ch + dy * sh * tiltK) * k, dn = (dx * sh + dy * ch * tiltK) * k; + const dLat = dn * R2D, dLon = (de * R2D) / Math.max(0.15, Math.cos(Cam.lat * D2R)); + Cam.lat = clamp(Cam.lat + dLat, -86, 86); Cam.lon = ((Cam.lon + dLon + 540) % 360) - 180; + return [dLat, dLon]; + }; + const zoomAt = (ratio, x, y, view) => { + const g = Cam.groundAt(view, x, y); + const nr = clamp(Cam.range * ratio, Cam.minRange, Cam.maxRange); const f = 1 - nr / Cam.range; + if (g && f > 0) { const p = geo.slerp(geo.vec(Cam.lat, Cam.lon), geo.vec(g[0], g[1]), f * 0.92); const [la, lo] = geo.ll(p); Cam.lat = la; Cam.lon = lo; } + Cam.range = nr; + }; + el.addEventListener('pointerdown', (e) => { + if (!Cam.enabled) return; + el.setPointerCapture?.(e.pointerId); + pts.set(e.pointerId, { x: e.clientX, y: e.clientY, b: e.button }); + Cam.cancel(); Cam.vel = null; Cam.interacting = true; Cam.lastInput = performance.now(); + if (pts.size === 1) { moved = 0; downAt = performance.now(); samples = []; } + last = null; bus.emit('globe-touch'); + }); + el.addEventListener('pointermove', (e) => { + if (!pts.has(e.pointerId) || !Cam.enabled) return; + const view = getView(); const p = pts.get(e.pointerId); const nx = e.clientX, ny = e.clientY; + const dx = nx - p.x, dy = ny - p.y; + if (pts.size === 1) { + moved += Math.abs(dx) + Math.abs(dy); + if (Cam.rig && Cam.rig.look) { Cam.rig.look(dx, dy); } + else if (p.b === 2 || e.shiftKey || e.ctrlKey) { Cam.heading -= dx * 0.006; Cam.tilt = clamp(Cam.tilt + dy * 0.005, 0, Cam.maxTilt(Cam.range)); } + else { const [dl, dn] = panBy(dx, dy, view); samples.push({ t: performance.now(), dl, dn }); } + p.x = nx; p.y = ny; + } else if (pts.size === 2) { + const ids = [...pts.keys()]; const a = pts.get(ids[0]), b = pts.get(ids[1]); + const before = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }; + p.x = nx; p.y = ny; + const after = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }; + const ratio = before.d / Math.max(after.d, 1); + const mdx = after.mx - before.mx, mdy = after.my - before.my; + if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(ratio); } + else { + if (Math.abs(ratio - 1) < 0.006 && Math.abs(mdy) > Math.abs(mdx) * 1.4) Cam.tilt = clamp(Cam.tilt - mdy * 0.006, 0, Cam.maxTilt(Cam.range)); + else { zoomAt(ratio, after.mx, after.my, view); panBy(mdx, mdy, view); Cam.heading += angDiff(before.ang, after.ang) * -1; } + } + moved += 99; + } + Cam.lastInput = performance.now(); + bus.emit('camera-move'); + }); + const up = (e) => { + if (!pts.has(e.pointerId)) return; + const wasSingle = pts.size === 1; pts.delete(e.pointerId); + if (wasSingle) { + Cam.interacting = false; + const dtp = performance.now() - downAt; + if (moved < 9 && dtp < 450) { const r = el.getBoundingClientRect(); bus.emit('tap', { x: e.clientX - r.left, y: e.clientY - r.top }); } + else if (!Cam.rig) { + const now = performance.now(); const rec = samples.filter((s) => now - s.t < 90); + if (rec.length > 1) { const span = (rec[rec.length - 1].t - rec[0].t) / 1000 || 0.016; const sl = rec.reduce((s, x) => s + x.dl, 0) / span, sn = rec.reduce((s, x) => s + x.dn, 0) / span; Cam.vel = { lat: clamp(sl, -90, 90), lon: clamp(sn, -180, 180), h: 0 }; if (prefersReduced()) Cam.vel = null; } + } + } + }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); + el.addEventListener('contextmenu', (e) => e.preventDefault()); + el.addEventListener('wheel', (e) => { + if (!Cam.enabled) return; e.preventDefault(); Cam.cancel(); Cam.vel = null; Cam.lastInput = performance.now(); + if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(Math.exp(e.deltaY * 0.0015)); return; } + const r = el.getBoundingClientRect(); zoomAt(Math.exp(e.deltaY * 0.0016), e.clientX - r.left, e.clientY - r.top, getView()); bus.emit('camera-move'); + }, { passive: false }); +}; diff --git a/mobile/vision/src/js/50-situations.js b/mobile/vision/src/js/50-situations.js new file mode 100644 index 000000000..955dfda8e --- /dev/null +++ b/mobile/vision/src/js/50-situations.js @@ -0,0 +1,227 @@ +/* Situations — the feed's genres (DESIGN.md §9). Live metrics are computed from the synthetic world; + names, handles, posts and reconstruction claims are illustrative. */ +const SIT = (GE.SIT = {}); +const fam = (f) => GE.FAM[f].color; +const RSit = rng(4242); +function walk(n, start, vol, drift = 0, seed = 1) { const r = rng(seed); let v = start; const out = []; for (let i = 0; i < n; i++) { v += r.gauss() * vol + drift; out.push(v); } return out; } + +/* user locale ("Near you") from time zone only — no geolocation */ +const TZMAP = { 'America/Los_Angeles': ['San Francisco', 37.77, -122.42], 'America/New_York': ['New York', 40.71, -74.0], 'America/Chicago': ['Chicago', 41.88, -87.63], 'America/Denver': ['Denver', 39.74, -104.99], 'Europe/London': ['London', 51.51, -0.13], 'Europe/Paris': ['Paris', 48.86, 2.35], 'Europe/Berlin': ['Berlin', 52.52, 13.4], 'Asia/Tokyo': ['Tokyo', 35.68, 139.69], 'Asia/Kolkata': ['Delhi', 28.61, 77.21], 'Asia/Kathmandu': ['Kathmandu', 27.72, 85.32], 'Australia/Sydney': ['Sydney', -33.87, 151.21], 'Asia/Singapore': ['Singapore', 1.35, 103.82] }; +let tz = 'UTC'; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch {} +const ME = TZMAP[tz] || ['London', 51.51, -0.13]; +GE.ME = { name: ME[0], lat: ME[1], lon: ME[2], tz }; + +/* ISS pass prediction over "you" on the synthetic orbit */ +function issPass(fromMs) { + const obs = geo.vec(GE.ME.lat, GE.ME.lon); const up = v3.norm(obs); + let best = null, inPass = false, cur = null; + for (let s = 0; s < 36 * 3600; s += 20) { + const ms = fromMs + s * 1000, t = (ms - NOW) / 1000; const g = astro.sun(ms).gmst; + const p = GE.satAt(GE.ISS, t, g); const d = v3.norm(v3.sub(p, obs)); const el = Math.asin(v3.dot(d, up)) * R2D; + if (el > 10) { if (!inPass) { inPass = true; cur = { start: ms, max: el, maxAt: ms }; } if (el > cur.max) { cur.max = el; cur.maxAt = ms; } } + else if (inPass) { inPass = false; cur.end = ms; if (!best && cur.max > 18) { best = cur; break; } } + } + return best || { start: fromMs + 3.2 * 3600000, max: 41, maxAt: fromMs + 3.25 * 3600000, end: fromMs + 3.3 * 3600000 }; +} +function busiestAirport() { + const t = GE.world.t; const counts = new Map(); + for (const f of GE.FLIGHTS) { if (f.kind !== 0) continue; const p = GE.flightAt(f, t); if (!p) continue; for (const ap of [p.from, p.to]) { if (!ap.iata || ap.iata.length !== 3) continue; const d = geo.dist(p.lat, p.lon, ap.lat, ap.lon) * EARTH_KM; if (d < 160) counts.set(ap.iata, (counts.get(ap.iata) || 0) + 1); } } + let best = 'ATL', bc = 0; for (const [k, v] of counts) if (v > bc) { bc = v; best = k; } + return { iata: best, ap: GE.apByIata[best], n: bc, perH: Math.round(bc * 5.6) }; +} +SIT.issPass = issPass; SIT.busiest = busiestAirport; +// daytime replay helper: if it is night at a place now, use the most recent local afternoon +function dayReplay(lat, lon, localHour = 15.5) { + if (astro.elev(NOW, lat, lon) > 8) return null; + const tzh = Math.round(lon / 15); const d = new Date(NOW); let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0) + (localHour - tzh) * 3600000; + while (t > NOW) t -= 86400000; return { at: t, start: t - 3 * 3600000, end: t + 3 * 3600000, replay: true }; +} +SIT.dayReplay = dayReplay; +const LAUNCH_T0 = NOW + (2 * 3600 + 14 * 60 + 33) * 1000; +const KOSHI_T = GE.scenes.FLOOD.startMs; +const AURORA_T = (() => { const d = new Date(NOW); d.setUTCHours(21, 30, 0, 0); if (d.getTime() < NOW) d.setUTCDate(d.getUTCDate() + 1); return d.getTime(); })(); + +/* ---------- situation definitions ---------- */ +const S = []; +const add = (o) => { o.updatedAt = o.updatedAt || NOW - (o.ageS || 60) * 1000; S.push(o); return o; }; +add({ + id: 'cyclone', short: 'Typhoon Halcyon', fam: 'haz', kind: 'cyclone', section: 'now', live: true, aspect: 1.25, ageS: 120, watchers: 48200, + title: 'Typhoon Halcyon tracks toward Luzon Strait', place: 'Philippine Sea', lat: 16.2, lon: 128.8, + camera: { lat: 15.2, lon: 127.6, range: 0.42, heading: -0.25, tilt: 0.42 }, thumbCam: { lat: 16.6, lon: 128.4, range: 0.3, heading: -0.2, tilt: 0.36 }, + layers: ['cyclones', 'radar', 'lightning', 'clouds', 'models', 'ais', 'flights'], source: 'Advisory feed (illustrative)', + metric: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [`${Math.round(c.wind)}km/h`, `${Math.round(c.p)} hPa · moving WNW 19 km/h`]; }, + metrics: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [{ v: Math.round(c.wind), u: 'km/h', l: 'Max sustained', s: 'Advisory · 2 min' }, { v: Math.round(c.p), u: 'hPa', l: 'Central pressure', s: 'Advisory · 2 min' }, { v: 610, u: 'km', l: 'To Luzon Strait', s: 'Computed · now' }]; }, + accessory: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return `${Math.round(c.wind)} km/h · ${Math.round(c.p)} hPa · updated 2 min ago`; }, + brief: { + lede: 'A compact, intense typhoon is moving west-northwest at 19 km/h. The forecast cone widens to ±370 km by day 5; most guidance recurves it north of Luzon before a weakening landfall near Taiwan. Ships are routing south of the track; two vessels are holding off Batanes.', + series: [ + { label: 'Max wind', unit: 'km/h', data: GE.CYCLONE.track.map((x) => x[3]), split: 6, band: 18, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v) }, + { label: 'Pressure', unit: 'hPa', data: GE.CYCLONE.track.map((x) => x[4]), split: 6, band: 6, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v), invert: true }, + { label: 'Ships within 500 km', unit: '', data: walk(24, 40, 2, -0.6, 3).map((v) => Math.max(0, Math.round(v))), src: 'AIS · 5 min bins · 40 s', fmt: (v) => v }, + ], + multiples: { kind: 'globe', label: 'Track, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? 'Now' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h' })) }, + sources: [['Tropical cyclone advisory (illustrative)', '2 min', 'live'], ['Geostationary IR mosaic', '10 min', 'live'], ['AIS vessel positions', '40 s', 'live'], ['Ensemble forecast tracks (14 members)', '3 h', 'stale']], + }, +}); +add({ + id: 'quake', short: 'M 6.4 · Honshu', fam: 'haz', kind: 'quake', section: 'now', live: true, aspect: 1.0, ageS: 5.4 * 3600, watchers: 21900, + title: 'M 6.4 off the coast of Honshu — largest in 24 h', place: 'Off eastern Honshu, Japan', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon, + camera: { lat: 35.2, lon: 141.0, range: 0.13, heading: 0.35, tilt: 0.75 }, layers: ['quakes', 'flights', 'ais', 'cctv'], source: 'Seismic network (illustrative)', + metric: () => [`M 6.4`, `32 km deep · 9 aftershocks`], metrics: () => [{ v: '6.4', u: 'Mw', l: 'Magnitude', s: 'Seismic net · 5 h' }, { v: 32, u: 'km', l: 'Depth', s: 'Seismic net · 5 h' }, { v: 9, u: '', l: 'Aftershocks ≥ M3.4', s: 'Seismic net · 12 min' }], + brief: { + lede: 'A shallow thrust event under the Pacific margin, felt widely along the coast. No tsunami was generated beyond 20 cm. Rail services paused for inspection and resumed within 90 minutes; flights into HND were briefly held.', + series: [{ label: 'Aftershocks per hour', unit: '', data: [0, 0, 0, 0, 0, 7, 5, 3, 3, 2, 2, 1, 1, 2, 1, 0, 1, 1], src: 'Seismic catalog · hourly · 12 min', fmt: (v) => v }, { label: 'Coastal tide gauge', unit: 'cm', data: walk(36, 0, 1.2, 0, 7).map((v, i) => (i > 18 && i < 26 ? v + 14 * Math.sin((i - 18) / 8 * Math.PI) : v)), band: 3, src: 'Tide gauge (illustrative) · 1 min · 3 min', fmt: (v) => v.toFixed(0) }], + multiples: { kind: 'bars', label: 'Felt reports by prefecture', items: [['Fukushima', 0.9], ['Ibaraki', 0.75], ['Miyagi', 0.6], ['Chiba', 0.45]] }, + sources: [['Seismic network catalog (illustrative)', '12 min', 'live'], ['Tide gauges', '3 min', 'live'], ['Rail status boards', '9 min', 'live']], + }, +}); +add({ + id: 'fires', short: 'Fires · Iberia', fam: 'haz', kind: 'fires', section: 'now', live: true, aspect: 1.33, ageS: 38 * 60, watchers: 9800, + title: 'Wildfire clusters across Iberia and Greece', place: 'Southern Europe', lat: 40.0, lon: -6.5, + camera: { lat: 39.2, lon: -6.0, range: 0.18, heading: 0.1, tilt: 0.55 }, layers: ['fires', 'perims', 'aq', 'wind', 'flights'], source: 'VIIRS 375 m hotspots', + metric: () => [`${fmt.n(GE.FIRES.filter((f) => f.t <= GE.world.t && (f.cluster === 'Central Portugal' || f.cluster === 'Galicia' || f.cluster === 'Attica' || f.cluster === 'Epirus' || f.cluster === 'Antalya')).length)}hotspots`, 'Portugal · Galicia · Attica · Antalya'], + metrics: () => [{ v: GE.FIRES.filter((f) => f.t <= GE.world.t && f.lat > 34 && f.lon > -10 && f.lon < 32).length, u: '', l: 'Active hotspots', s: 'VIIRS · 38 min' }, { v: '3.4k', u: 'ha', l: 'Mapped perimeter', s: 'Perimeters · 3 h' }, { v: 31, u: 'km/h', l: 'Wind, Galicia', s: 'Model · 1 h' }], + brief: { lede: 'Dry easterly winds are pushing fire fronts toward the Atlantic in central Portugal and Galicia. Smoke is degrading air quality in Porto; the Attica cluster is contained on its north flank.', + series: [{ label: 'Hotspots, 6 h bins', unit: '', data: [12, 18, 44, 61, 58, 80, 102, 96, 120, 140, 133, 151], src: 'VIIRS / MODIS · 6 h · 38 min', fmt: (v) => v }, { label: 'PM2.5 Porto', unit: 'µg/m³', data: walk(30, 14, 3, 1.4, 11), band: 8, src: 'Air-quality sensors · 1 h · 22 min', fmt: (v) => Math.round(v) }], + multiples: { kind: 'globe', label: 'Spread, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? 'Now' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h', cam: { lat: 40.6, lon: -7.8, range: 0.05, heading: 0, tilt: 0.3 } })) }, + sources: [['VIIRS 375 m active fires', '38 min', 'live'], ['Fire perimeters (illustrative)', '3 h', 'stale'], ['Wind model', '1 h', 'live']] }, +}); +add({ + id: 'milair', short: 'Military air · Baltic', fam: 'sky', kind: 'mil', section: 'now', live: true, aspect: 1.0, ageS: 30, watchers: 17300, + title: 'Military air activity over the Baltic', place: 'Baltic Sea', lat: 56.5, lon: 19.5, + camera: { lat: 56.0, lon: 19.0, range: 0.16, heading: 0.0, tilt: 0.5 }, layers: ['military', 'flights', 'gnss', 'holding'], source: 'ADS-B / MLAT', + metric: () => [`38aircraft`, '3 tankers holding · GNSS jamming'], metrics: () => [{ v: 38, u: '', l: 'Military aircraft', s: 'ADS-B/MLAT · 30 s' }, { v: 3, u: '', l: 'Tankers in orbit', s: 'ADS-B · 30 s' }, { v: '41', u: '%', l: 'Flights w/ GNSS loss', s: 'NIC/NACp · 10 min' }], + brief: { lede: 'Two tanker orbits are active over southern Sweden with fighter pairs cycling. GNSS integrity reports from airliners show a jamming footprint centred off Kaliningrad, affecting 41% of overflights in the last hour.', + series: [{ label: 'Military aircraft aloft', unit: '', data: walk(36, 26, 2, 0.35, 21).map(Math.round), src: 'ADS-B / MLAT · 10 min · 30 s', fmt: (v) => v }, { label: 'GNSS-degraded flights', unit: '%', data: walk(36, 30, 2.4, 0.3, 23), band: 6, src: 'Aircraft integrity reports · 10 min', fmt: (v) => Math.round(v) }], + multiples: { kind: 'bars', label: 'By type', items: [['Fighters', 0.8], ['Tankers', 0.22], ['ISR', 0.3], ['Transport', 0.5]] }, + sources: [['ADS-B / MLAT network', '30 s', 'live'], ['GNSS integrity (NIC/NACp)', '10 min', 'live']] }, +}); +add({ + id: 'iss', fam: 'orbit', kind: 'iss', section: 'near', live: true, aspect: 1.33, ageS: 4, watchers: 6400, + title: `ISS pass over ${GE.ME.name}`, place: `${GE.ME.name} (from your time zone)`, lat: GE.ME.lat, lon: GE.ME.lon, + camera: { lat: GE.ME.lat, lon: GE.ME.lon, range: 0.55, heading: 0, tilt: 0.55 }, layers: ['crewed', 'tracks', 'sats', 'trains'], source: 'TLE propagation', + dyn: true, + metric: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); const s = (p.start - Date.now()) / 1000; return [s > 0 ? `in ${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : 'now', `max ${Math.round(p.max)}° · ${fmt.hm(p.maxAt)} UTC`]; }, + metrics: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); return [{ v: fmt.hm(p.start), u: 'UTC', l: 'Rises', s: 'TLE · 4 s' }, { v: Math.round(p.max), u: '°', l: 'Max elevation', s: 'Computed' }, { v: Math.round((p.end - p.start) / 60000), u: 'min', l: 'Visible for', s: 'Computed' }]; }, + brief: { lede: 'The station will rise in the west-southwest and climb high across your sky. If the sky is clear and the pass is after dusk it will be the brightest point-like object, brighter than any star.', series: [{ label: 'Elevation during pass', unit: '°', data: Array.from({ length: 30 }, (_, i) => Math.max(0, Math.sin((i / 29) * Math.PI)) * 54), src: 'Propagated from TLE · computed', fmt: (v) => Math.round(v) }], sources: [['Two-line elements (illustrative epoch)', '4 h', 'live'], ['Sun/terminator', 'computed', 'live']] }, +}); +add({ + id: 'airport', short: 'Busiest: ATL', fam: 'sky', kind: 'airport', section: 'always', live: true, aspect: 1.25, ageS: 20, watchers: 5200, + title: 'Busiest airport right now', place: 'Hartsfield–Jackson Atlanta', lat: 33.6407, lon: -84.4277, + camera: { lat: 33.636, lon: -84.4277, range: 0.0042, heading: 0.35, tilt: 1.05 }, layers: ['flights', 'airports', 'holding', 'traffic'], source: 'ADS-B', + metric: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [`${b.iata} ${b.perH} mvts/h`, `${b.ap.city} · ${b.n} aircraft within 160 km`]; }, + metrics: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [{ v: b.perH, u: '/h', l: 'Movements', s: 'ADS-B · 20 s' }, { v: b.n, u: '', l: 'Aircraft < 160 km', s: 'ADS-B · 20 s' }, { v: 4, u: 'min', l: 'Avg. taxi-out delay', s: 'Derived · 15 min' }]; }, + brief: { lede: 'Arrivals are landing on the two southern runways in a steady stream; departures are pushing west. The evening bank peaks in 40 minutes.', series: [{ label: 'Movements per hour', unit: '', data: [62, 70, 88, 94, 101, 97, 90, 85, 92, 104, 112, 108], src: 'ADS-B · hourly · 20 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'Top 4 right now', items: [['ATL', 0.95], ['DFW', 0.84], ['DXB', 0.8], ['LHR', 0.74]] }, sources: [['ADS-B network', '20 s', 'live'], ['Airport board (scraped, illustrative)', '2 min', 'live']] }, +}); +add({ + id: 'novosel', short: 'Fort Novosel circuits', fam: 'sky', kind: 'heli', section: 'always', live: true, aspect: 1.5, ageS: 15, watchers: 3100, ride: true, + title: 'Fort Novosel helicopter school — jump into the chopper', place: 'Cairns Army Airfield, Alabama', lat: 31.276, lon: -85.713, + camera: { lat: 31.268, lon: -85.705, range: 0.0019, heading: -0.6, tilt: 1.12 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2), + metric: () => [`${GE.HELI_CIRCUITS.filter((h) => h.field).length}in circuits`, 'Cairns + 3 stagefields · 500 ft AGL'], + metrics: () => [{ v: GE.HELI_CIRCUITS.filter((h) => h.field).length, u: '', l: 'Helicopters in circuits', s: 'ADS-B · 15 s' }, { v: 500, u: 'ft', l: 'Pattern altitude', s: 'Observed' }, { v: 5.5, u: 'min', l: 'Circuit time', s: 'Derived' }], + brief: { lede: 'Student pilots are flying rectangular traffic patterns at Cairns and three stagefields: upwind, crosswind, downwind, base, final — over and over. Tap Ride to sit in the left seat of one of them.', series: [{ label: 'Helicopters aloft', unit: '', data: [2, 4, 9, 14, 17, 19, 19, 18, 19, 17, 19, 19], src: 'ADS-B / MLAT · hourly · 15 s', fmt: (v) => v }, { label: 'Groundspeed, HAWK 21', unit: 'kt', data: Array.from({ length: 40 }, (_, i) => 70 + 12 * Math.sin(i / 3.2) + (i % 10 > 7 ? -30 : 0)), src: 'ADS-B · 1 s · 15 s', fmt: (v) => Math.round(v) }], sources: [['ADS-B / MLAT', '15 s', 'live']] }, +}); +add({ + id: 'launch', short: 'Launch · SLC-40', fam: 'orbit', kind: 'launch', section: 'now', live: true, aspect: 1.5, ageS: 300, watchers: 33800, + title: 'Rideshare launch from SLC-40 — countdown', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772, + camera: { lat: 28.552, lon: -80.590, range: 0.0011, heading: 0.75, tilt: 1.2 }, layers: ['launches', 'flights', 'ais', 'tracks'], source: 'Range schedule (illustrative)', + metric: () => [`T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}`, 'Rideshare to SSO · trajectory NE'], + metrics: () => [{ v: fmt.dur((LAUNCH_T0 - Date.now()) / 1000), u: '', l: 'Countdown', s: 'Range · 5 min' }, { v: 92, u: '%', l: 'Weather go', s: 'Forecast · 1 h' }, { v: 14, u: '', l: 'Vessels in hazard area', s: 'AIS · 40 s' }], + brief: { lede: 'Vehicle is vertical on the pad. The hazard area offshore is still being cleared — 14 vessels remain inside. Trajectory heads northeast along the coast.', series: [{ label: 'Vessels in hazard area', unit: '', data: [41, 39, 36, 33, 31, 28, 24, 21, 19, 17, 15, 14], src: 'AIS · 15 min · 40 s', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['AIS', '40 s', 'live']] }, +}); +add({ + id: 'koshi', short: 'Bhote Koshi · 4D', fam: 'haz', kind: 'recon', section: 'recon', live: false, aspect: 1.33, ageS: 53 * 86400, watchers: 12800, thread: 'koshi', d4: true, + title: 'Bhote Koshi outburst flood — 4D reconstruction', place: 'Rasuwa, Nepal · 26 Aug 2026', lat: 28.215, lon: 85.36, + camera: { lat: 28.212, lon: 85.352, range: 0.0036, heading: 0.35, tilt: 1.08 }, time: { at: KOSHI_T + 2.2 * 3600000, start: KOSHI_T - 3 * 3600000, end: KOSHI_T + 9 * 3600000 }, + layers: ['signed', 'holo', 'floods', 'helis'], source: 'Thread · 23 observations', + metric: () => ['23obs · 9hyp', '14 people · 3 AI proposals'], metrics: () => [{ v: 23, u: '', l: 'Observations', s: 'Ledger · 2 h' }, { v: 9, u: '', l: 'Hypotheses', s: 'Ledger · 2 h' }, { v: 14, u: '', l: 'Contributors', s: 'Thread' }], + brief: { lede: 'An outburst flood was observed on the Bhote Koshi on 26 August 2026. This thread reconstructs the crest’s passage from Rasuwagadhi down to Syaphrubesi using signed witness captures, river-gauge readings and satellite imagery. Witness posts are illustrative.', series: [{ label: 'River stage, Syaphrubesi (illustrative)', unit: 'm', data: Array.from({ length: 40 }, (_, i) => 3.1 + 4.7 * Math.exp(-Math.pow((i - 16) / 5, 2)) + (i > 16 ? 0.6 : 0)), band: 0.4, src: 'Gauge (illustrative) · 15 min', fmt: (v) => v.toFixed(1) }], sources: [['Signed witness captures (illustrative)', '53 d', 'stale'], ['Optical satellite tiles, before/after', '50 d', 'stale']] }, +}); +add({ + id: 'concert', short: 'Stadium encore · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.25, ageS: 20 * 3600, watchers: 88200, thread: 'concert', d4: true, + title: 'Stadium encore in 4D — from 2,311 crowd captures', place: 'Wembley, London · last night', lat: 51.556, lon: -0.2796, + camera: { lat: 51.5552, lon: -0.2810, range: 0.00034, heading: 0.55, tilt: 1.12 }, time: { at: NOW - 20 * 3600000 + 0.0, start: NOW - 22 * 3600000, end: NOW - 19 * 3600000 }, + layers: ['signed', 'holo', 'venues', 'cctv'], source: 'Thread · 2,311 signed captures', nightThumb: true, + metric: () => ['2,311captures', '4D · 41 min · 86 seat blocks'], metrics: () => [{ v: '2,311', u: '', l: 'Signed captures', s: 'Ledger · 20 h' }, { v: 86, u: '', l: 'Seat blocks covered', s: 'Derived' }, { v: '±9', u: 'ms', l: 'Time alignment', s: 'Audio sync' }], + brief: { lede: 'A crowd-sourced reconstruction of last night’s encore: 2,311 signed phone captures aligned by audio fingerprint and IMU traces into a single 4D scene you can scrub. Performer and attendee identities are not part of the record.', series: [{ label: 'Captures per minute', unit: '', data: [3, 5, 9, 21, 64, 140, 188, 162, 120, 96, 72, 40], src: 'Signed bundles · 1 min', fmt: (v) => v }], sources: [['Signed crowd captures (illustrative)', '20 h', 'stale']] }, +}); +add({ + id: 'climb', short: 'Tower climb · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.5, ageS: 9 * 3600, watchers: 41500, thread: 'climb', d4: true, + title: 'Free-climber on a Midtown tower — reconstructed', place: 'Midtown Manhattan · this morning', lat: 40.7553, lon: -73.9838, + camera: { lat: 40.7528, lon: -73.9870, range: 0.00027, heading: 0.62, tilt: 1.2 }, time: { at: NOW - 9 * 3600000, start: NOW - 11 * 3600000, end: NOW - 7 * 3600000 }, + layers: ['helis', 'signed', 'holo', 'cctv'], source: 'Thread · 3 news helicopters (ADS-B)', + metric: () => ['41clips · 3helis', 'Fictional tower · ascent 2 h 14 m'], metrics: () => [{ v: 41, u: '', l: 'Witness clips', s: 'Ledger · 9 h' }, { v: 3, u: '', l: 'News helicopters', s: 'ADS-B · 9 h' }, { v: 412, u: 'm', l: 'Tower height', s: 'Model (fictional tower)' }], + brief: { lede: 'Three news helicopters orbited the tower for two hours; their ADS-B tracks give precise camera positions for every broadcast frame. Combined with 41 signed street-level clips, the climber’s height over time is reconstructed to ±3 m. The tower and people are fictional.', series: [{ label: 'Climber height (reconstructed)', unit: 'm', data: Array.from({ length: 30 }, (_, i) => Math.min(380, i * 14 + Math.sin(i) * 6)), band: 3, src: 'Hypothesis H2 · ±3 m', fmt: (v) => Math.round(v) }], sources: [['ADS-B: 3 helicopters', '9 h', 'stale'], ['Signed street clips (illustrative)', '9 h', 'stale']] }, +}); +add({ + id: 'suez', short: 'Suez queue', fam: 'sea', kind: 'queue', section: 'always', live: true, aspect: 1.0, ageS: 40, watchers: 7700, + title: 'Ships queuing at Suez', place: 'Gulf of Suez & Port Said anchorages', lat: 30.3, lon: 32.4, + camera: { lat: 29.75, lon: 32.45, range: 0.045, heading: 0.2, tilt: 0.85 }, layers: ['ais', 'tankers', 'ports'], source: 'AIS', + metric: () => [`${GE.SHIPS.filter((s) => s.queue === 'suez').length}waiting`, 'avg wait 31 h · southbound convoy 06:00'], + metrics: () => [{ v: GE.SHIPS.filter((s) => s.queue === 'suez').length, u: '', l: 'Ships at anchor', s: 'AIS · 40 s' }, { v: 31, u: 'h', l: 'Average wait', s: 'Derived · 1 h' }, { v: 52, u: '', l: 'Transits / 24 h', s: 'AIS · 1 h' }], + brief: { lede: 'The anchorage off Suez is fuller than its 30-day median; northbound tankers are waiting longest.', series: [{ label: 'Ships at anchor', unit: '', data: walk(30, 52, 2.2, 0.75, 31).map(Math.round), band: 5, src: 'AIS · 6 h · 40 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'By type', items: [['Tanker', 0.82], ['Bulk', 0.6], ['Container', 0.45], ['LNG', 0.22]] }, sources: [['AIS terrestrial + satellite', '40 s', 'live']] }, +}); +add({ + id: 'aurora', short: 'Aurora · Kp 6', fam: 'wx', kind: 'aurora', section: 'near', live: true, aspect: 1.33, ageS: 600, watchers: 15100, + title: 'Aurora nowcast — Kp 6 expected tonight', place: 'Northern Scandinavia & Iceland', lat: 69.6, lon: 18.9, + camera: { lat: 62.5, lon: 12.0, range: 0.62, heading: 0.15, tilt: 0.95 }, time: { at: AURORA_T }, layers: ['clouds', 'signed', 'holo', 'flights'], source: 'Space-weather forecast', auroraK: 1.15, + metric: () => ['Kp 6tonight', 'Tromsø 92% · cloud 18%'], metrics: () => [{ v: 6, u: 'Kp', l: 'Forecast peak', s: 'Space weather · 10 min' }, { v: 92, u: '%', l: 'Chance, Tromsø', s: 'Model · 10 min' }, { v: 18, u: '%', l: 'Cloud cover', s: 'Satellite · 15 min' }], + brief: { lede: 'A coronal mass ejection is arriving earlier than forecast. The auroral oval will push south after dark; sky captures from Tromsø already show a bright arc.', series: [{ label: 'Kp forecast', unit: '', data: [2, 2, 3, 3, 4, 5, 6, 6, 5, 4, 4, 3], band: 1, src: 'Space-weather forecast · 3 h · 10 min', fmt: (v) => v }], sources: [['Space-weather forecast', '10 min', 'live'], ['Sky captures (illustrative)', '18 min', 'live']] }, +}); +add({ + id: 'drones', short: 'Drone corridor', fam: 'ground', kind: 'drone', section: 'always', live: false, aspect: 1.25, ageS: 3600, watchers: 2300, + title: 'Delivery-drone corridor — today’s replay', place: 'Suburban Texas (illustrative operator)', lat: 30.588, lon: -96.29, + camera: { lat: 30.578, lon: -96.300, range: 0.0013, heading: 0.5, tilt: 1.1 }, layers: ['drones', 'traffic'], source: 'Remote ID broadcasts', time: dayReplay(30.588, -96.29, 14.5), + metric: () => ['212drops', '16 drones · 4.1 min median'], metrics: () => [{ v: 212, u: '', l: 'Deliveries today', s: 'Remote ID · 1 h' }, { v: 16, u: '', l: 'Drones active', s: 'Remote ID · 1 min' }, { v: 4.1, u: 'min', l: 'Median trip', s: 'Derived' }], + brief: { lede: 'Remote ID broadcasts let anyone replay a corridor: out and back at 60 m, a hover-and-lower at each drop.', series: [{ label: 'Drops per hour', unit: '', data: [0, 0, 4, 12, 19, 22, 25, 21, 24, 27, 23, 18], src: 'Remote ID · hourly', fmt: (v) => v }], sources: [['Remote ID receivers (illustrative)', '1 min', 'live']] }, +}); +add({ + id: 'marathon', short: 'Lakeshore 42K', fam: 'ground', kind: 'race', section: 'always', live: true, aspect: 1.0, ageS: 10, watchers: 4600, + title: 'Lakeshore 42K — runner trackers', place: 'Chicago lakefront (fictional race)', lat: 41.89, lon: -87.625, + camera: { lat: 41.875, lon: -87.630, range: 0.0026, heading: 0.15, tilt: 1.05 }, layers: ['venues', 'transit', 'traffic'], source: 'Race trackers (illustrative)', time: dayReplay(41.88, -87.63, 9.5), + metric: () => ['260trackers', 'leader at 31.4 km · 2:21 pace'], metrics: () => [{ v: 260, u: '', l: 'Live trackers', s: 'Race app · 10 s' }, { v: 31.4, u: 'km', l: 'Leader', s: '10 s' }, { v: '3:21', u: '/km', l: 'Median pace', s: 'Derived' }], + brief: { lede: 'Bibs broadcast positions every 10 seconds; the field has stretched to 9 km.', series: [{ label: 'Leader pace', unit: 's/km', data: walk(30, 200, 1.5, 0, 41), band: 2, src: 'Tracker · 1 km', fmt: (v) => Math.round(v) }], sources: [['Race trackers (fictional race)', '10 s', 'live']] }, +}); +add({ + id: 'gnss', short: 'GNSS jamming', fam: 'infra', kind: 'gnss', section: 'now', live: true, aspect: 1.25, ageS: 600, watchers: 6100, + title: 'GNSS jamming hot spots', place: 'Baltic · Black Sea · Eastern Med', lat: 50, lon: 27, + camera: { lat: 49.0, lon: 26.0, range: 0.42, heading: 0.0, tilt: 0.35 }, layers: ['gnss', 'flights', 'military'], source: 'Aircraft integrity reports', + metric: () => ['8hot spots', 'Baltic 41% · E. Med 28%'], metrics: () => [{ v: 8, u: '', l: 'Hot spots', s: '10 min' }, { v: 41, u: '%', l: 'Baltic flights affected', s: '10 min' }, { v: 28, u: '%', l: 'E. Med', s: '10 min' }], + brief: { lede: 'Aircraft broadcast their own position-integrity; mapping drops in that integrity reveals where navigation signals are being jammed.', series: [{ label: 'Affected flights', unit: '%', data: walk(24, 30, 2, 0.4, 51), band: 5, src: 'NIC/NACp · 1 h', fmt: (v) => Math.round(v) }], sources: [['ADS-B integrity fields', '10 min', 'live']] }, +}); +add({ + id: 'starlink', fam: 'orbit', kind: 'train', section: 'near', live: true, aspect: 1.0, ageS: 90, watchers: 8800, + title: 'Fresh satellite train — visible at dusk', place: 'Low Earth orbit, 312 km', lat: 30, lon: -60, + camera: null, layers: ['trains', 'sats', 'tracks'], source: 'TLE propagation', dyn: true, + metric: () => ['24satellites', 'orbit raising · 312 km'], metrics: () => [{ v: 24, u: '', l: 'Satellites in train', s: 'TLE · 90 s' }, { v: 312, u: 'km', l: 'Altitude', s: 'TLE' }, { v: 7.73, u: 'km/s', l: 'Speed', s: 'Computed' }], + brief: { lede: 'A freshly launched batch flies in a tight line before raising orbit — at dusk it looks like a string of pearls.', series: [{ label: 'Altitude (orbit raising)', unit: 'km', data: Array.from({ length: 20 }, (_, i) => 290 + i * 1.2), src: 'TLE · daily', fmt: (v) => Math.round(v) }], sources: [['Two-line elements', '90 s', 'live']] }, +}); +add({ + id: 'squawk', fam: 'sky', kind: 'squawk', section: 'now', live: true, aspect: 1.25, ageS: 300, watchers: 12000, + title: 'Squawk 7700 — diversion in progress', place: 'Over the North Atlantic', lat: 50, lon: -30, dyn: true, + camera: null, layers: ['squawk', 'flights'], source: 'ADS-B', + metric: () => ['7700emergency', 'diverting · descending'], metrics: () => [{ v: 7700, u: '', l: 'Squawk', s: 'ADS-B · 5 s' }, { v: 'FL240', u: '', l: 'Descending through', s: '5 s' }, { v: 34, u: 'min', l: 'To diversion', s: 'Computed' }], + brief: { lede: 'An airliner set its transponder to 7700 and turned toward the nearest suitable airport. Replay the last 20 minutes.', series: [{ label: 'Altitude', unit: 'ft', data: Array.from({ length: 30 }, (_, i) => (i < 8 ? 37000 : 37000 - (i - 8) * 600)), src: 'ADS-B · 1 min', fmt: (v) => fmt.n(v) }], sources: [['ADS-B', '5 s', 'live']] }, +}); +add({ + id: 'strait', short: 'Singapore Strait', fam: 'sea', kind: 'strait', section: 'always', live: true, aspect: 1.5, ageS: 40, watchers: 3900, + title: 'Singapore Strait — the world’s busiest lane', place: 'Singapore', lat: 1.25, lon: 103.9, + camera: { lat: 1.18, lon: 103.85, range: 0.022, heading: 0.1, tilt: 0.9 }, layers: ['ais', 'tankers', 'ports', 'flights'], source: 'AIS', + metric: () => ['1,912transits/day', '90 at anchor · 2 dark'], metrics: () => [{ v: '1,912', u: '', l: 'Transits / 24 h', s: 'AIS · 40 s' }, { v: 90, u: '', l: 'At anchor', s: 'AIS · 40 s' }, { v: 2, u: '', l: 'AIS gaps', s: '1 h' }], + brief: { lede: 'Two traffic separation lanes, one of the densest anchorages anywhere.', series: [{ label: 'Transits per hour', unit: '', data: walk(24, 80, 4, 0, 61).map(Math.round), src: 'AIS · hourly', fmt: (v) => v }], sources: [['AIS', '40 s', 'live']] }, +}); + +for (const s of S) if (s.time && s.time.replay) { s.live = false; s.replayLabel = 'Today ' + new Date(s.time.at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }) + ' UTC'; } +SIT.list = S; +SIT.byId = Object.fromEntries(S.map((s) => [s.id, s])); +SIT.sections = [ + { id: 'now', title: 'Happening now', sub: 'Live' }, { id: 'near', title: 'Near you', sub: GE.ME.name }, { id: 'recon', title: 'Reconstructions', sub: '4D · signed' }, { id: 'always', title: 'Always on', sub: 'Worth a look' }, +]; +// dynamic cameras +SIT.cameraFor = function (s) { + if (s.id === 'starlink') { const g = GE.world.sun.gmst; const p = GE.satAt(GE.SATS.find((x) => x.kind === 5), GE.world.t, g); const [la, lo] = geo.ll(p); return { lat: la - 6, lon: lo - 8, range: 0.32, heading: 0.6, tilt: 0.85 }; } + if (s.id === 'squawk') { const f = GE.FLIGHTS[GE.world.squawks[0]]; const p = GE.flightAt(f, GE.world.t) || { lat: 50, lon: -30 }; return { lat: p.lat - 1.2, lon: p.lon - 1, range: 0.06, heading: 0.3, tilt: 0.85 }; } + if (s.id === 'iss') { const p = GE.satAt(GE.ISS, GE.world.t, GE.world.sun.gmst); const [la, lo] = geo.ll(p); return { lat: la - 9, lon: lo - 4, range: 0.48, heading: 0.35, tilt: 0.78 }; } + return s.camera; +}; +SIT.pin = (s) => { if (s.id === 'starlink' || s.id === 'squawk' || s.id === 'iss') { const c = SIT.cameraFor(s); return null; } return { id: s.id, lat: s.lat, lon: s.lon, title: s.title, col: [...hex2rgb(fam(s.fam)), 1], seed: hash01(s.id.length) }; }; diff --git a/mobile/vision/src/js/60-shell.js b/mobile/vision/src/js/60-shell.js new file mode 100644 index 000000000..872dccfcd --- /dev/null +++ b/mobile/vision/src/js/60-shell.js @@ -0,0 +1,147 @@ +/* Shell: tabs, Liquid-Glass tab bar with sliding lens, tab-bar accessory (followed situation), + minimize-on-scroll, layout (phone / two-pane / hinge), overlay labels, toasts. */ +const APP = (GE.app = { tab: 'now', mode: '', following: 'cyclone', wide: false }); +const appEl = () => $('#app'); +APP.isWide = () => { try { return matchMedia('(min-width: 600px)').matches; } catch { return innerWidth >= 600; } }; + +APP.toast = function (html, ms = 2200) { + const t = $('#toast'); t.innerHTML = html; t.classList.add('on'); clearTimeout(APP._tt); APP._tt = setTimeout(() => t.classList.remove('on'), ms); +}; +APP.setMode = function (m) { APP.mode = m; appEl().dataset.mode = m; bus.emit('mode', m); APP.updateChrome(); }; + +/* ---------- tabs ---------- */ +APP.setTab = function (tab, opts = {}) { + const prev = APP.tab; + if (prev === tab && !opts.force) { if (tab === 'now') $('#v-now').scrollTo({ top: 0, behavior: 'smooth' }); return; } + if (APP.mode === 'situation') GE.sheet.close(true); + if (APP.mode === 'ride') GE.ride.exit(true); + if (prev === 'capture') GE.capture.exit(); + if (prev === 'threads' && GE.threads.ws) GE.threads.closeWs(true); + APP.tab = tab; appEl().dataset.tab = tab; + $$('.tab').forEach((b) => { b.classList.toggle('on', b.dataset.tab === tab); b.setAttribute('aria-selected', b.dataset.tab === tab); }); + APP.moveLens(); + const wide = APP.isWide(); + $$('.pane .view').forEach((v) => v.classList.remove('on')); + const map = { now: '#v-now', threads: '#v-threads', you: '#v-you', globe: wide ? '#v-layers' : null }; + if (map[tab]) $(map[tab]).classList.add('on'); + $('#v-capture').classList.toggle('on', tab === 'capture'); + if (tab === 'capture') GE.capture.enter(); + if (tab === 'globe' && wide) GE.gui.renderLayersPane(); + APP.minimize(false); + APP.cameraForTab(tab, prev); + APP.updateChrome(); + haptic(6); + bus.emit('tab', tab); +}; +APP.cameraForTab = function (tab, prev) { + const Cm = GE.cam; const wide = APP.isWide(); + if (tab === 'now') { + Cm.flyTo(APP.heroCam(), { duration: prev ? 1.2 : 0 }); + Cm.autoSpin = 0.3; + } else if (tab === 'globe') { + Cm.autoSpin = 0; + if (prev === 'now' || prev === 'you' || prev === 'threads' || !prev) Cm.flyTo({ range: Math.min(2.7, Math.max(Cm.range, 0.5)), shiftY: wide ? 0 : 0.06, shiftX: 0, tilt: 0 }, { duration: 1.0 }); + } else if (tab === 'you' || tab === 'threads') { Cm.autoSpin = 0.3; if (!wide) Cm.flyTo(APP.heroCam(), { duration: 0.9 }); } +}; +APP.moveLens = function () { + const on = $(`.tab[data-tab="${APP.tab}"]`); const lens = $('#tablens'); if (!on || !lens) return; + lens.style.transform = `translateX(${on.offsetLeft}px)`; lens.style.width = on.offsetWidth + 'px'; +}; +APP.minimize = function (on) { + if (APP._mini === on) return; APP._mini = on; + $('#tabwrap').classList.toggle('mini', on); + $('#accHost').classList.toggle('hidden', on || !APP.showAcc()); + setTimeout(APP.moveLens, 460); requestAnimationFrame(APP.moveLens); +}; +APP.showAcc = () => !!APP.following && (APP.tab === 'now' || APP.tab === 'threads' || APP.tab === 'you' || (APP.isWide() && APP.tab === 'globe')) && !APP.mode; +APP.updateChrome = function () { + const ride = APP.mode === 'ride', sit = APP.mode === 'situation'; + $('#tabwrap').classList.toggle('hidden', ride || (sit && !APP.isWide()) || APP.intro); + $('#accHost').classList.toggle('hidden', !APP.showAcc() || APP._mini || APP.intro); + $('#back').classList.toggle('on', sit); + $('#sitbtns').classList.toggle('on', sit); + // globe ribbon/dock offsets when the sheet is open (phone) + const gb = $('#gbottom'); + gb.style.opacity = sit && !APP.isWide() ? '0' : ''; gb.style.pointerEvents = sit && !APP.isWide() ? 'none' : ''; +}; + +/* ---------- accessory ---------- */ +APP.renderAccessory = function () { + const s = GE.SIT.byId[APP.following]; if (!s) return; + const html = `
    ${esc(GE.CYCLONE.name)}
    ${esc(s.accessory ? s.accessory() : '')}
    ${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}`; + $('#acc').innerHTML = html; + $('#accMini').innerHTML = `
    ${esc(GE.CYCLONE.name)}
    ${esc(s.accessory ? s.accessory() : '')}
    `; +}; +function sparkPath(data, w, h, col, sw = 1.6) { + const mn = Math.min(...data), mx = Math.max(...data), n = data.length; + const pts = data.map((v, i) => [(i / (n - 1)) * (w - 4) + 2, h - 3 - ((v - mn) / (mx - mn || 1)) * (h - 6)]); + const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(''); + const l = pts[pts.length - 1]; + return ``; +} +GE.sparkPath = sparkPath; + +/* ---------- overlay labels on the globe ---------- */ +const LBL = { els: new Map() }; +APP.updateLabels = function (view) { + const host = $('#labels'); if (!host) return; + const want = []; + const showPins = (APP.tab === 'globe' || APP.isWide()) && !APP.mode && !APP.intro; + if (showPins) for (const s of GE.SIT.list) { + if (s.dyn) continue; + const short = s.short || s.title.split(/ [—–-] /)[0].slice(0, 28); + want.push({ k: 's:' + s.id, lat: s.lat, lon: s.lon, alt: 0.2, pr: s.watchers, html: `${esc(short)}`, w: short.length * 6.6 + 32, cls: 'glass', onclick: () => GE.sheet.open(s.id, { fromGlobe: true }) }); + } + if (GE.world.sel && GE.world.sel.v && APP.mode !== 'ride') want.push({ k: 'sel', v: GE.world.sel.v, pr: 1e9, html: `${esc(GE.world.sel.title)}`, w: 90, cls: 'plain tag' }); + for (const [k, l] of GE.extraLabels || []) want.push({ k, pr: 1e8, w: 120, ...l }); + want.sort((a, b) => b.pr - a.pr); + const placed = []; const shown = new Set(); + const cap = view.range > 1.2 ? 7 : view.range > 0.3 ? 12 : 40; + for (const l of want) { + const p = l.v || geo.vec(l.lat, l.lon, l.alt || 0); + const s = GE.world.project(view, p); + if (!s || !s.vis || shown.size >= cap) continue; + const tag = l.cls && l.cls.includes('tag'); + const box = tag ? [s.x + 10, s.y - 13, s.x + 10 + l.w, s.y + 13] : [s.x - l.w / 2, s.y - 36, s.x + l.w / 2, s.y - 8]; + if (placed.some((b) => !(box[2] < b[0] - 4 || box[0] > b[2] + 4 || box[3] < b[1] - 3 || box[1] > b[3] + 3))) continue; + placed.push(box); shown.add(l.k); + let el = LBL.els.get(l.k); + if (!el) { el = h('button', { class: 'lbl ' + (l.cls || '') }); el.innerHTML = l.html; if (l.onclick) el.addEventListener('click', l.onclick); else el.style.pointerEvents = 'none'; host.append(el); LBL.els.set(l.k, el); } + else if (el._html !== l.html) el.innerHTML = l.html; + el._html = l.html; el.style.opacity = '1'; + el.style.transform = `translate3d(${s.x.toFixed(1)}px, ${(s.y - (tag ? 0 : 8)).toFixed(1)}px, 0) translate(${tag ? '10px, -50%' : '-50%, -100%'})`; + } + for (const [k, el] of LBL.els) if (!shown.has(k)) { if (!want.some((w) => w.k === k)) { el.remove(); LBL.els.delete(k); } else el.style.opacity = '0'; } +}; +APP.heroCam = function () { + const W = GE.world; const sun = W.sun; + if (!APP._heroLon || Math.abs((APP._heroT || 0) - Date.now()) > 600000) { + let best = sun.lon + 70, bs = -1; + for (const off of [-80, -65, 65, 80]) { const lon0 = sun.lon + off; let sum = 0; for (let la = -50; la <= 65; la += 7) for (let lo = -60; lo <= 60; lo += 7) { const L = lon0 + lo; const v = geo.vec(la, L); if (v3.dot(v, sun.dir) < -0.05 && geo.dist(24, lon0, la, L) < 1.15) sum += W.lightsAt(la, ((L + 540) % 360) - 180); } if (sum > bs) { bs = sum; best = lon0; } } + APP._heroLon = ((best + 540) % 360) - 180; APP._heroT = Date.now(); + } + const sl = APP._heroLon - 66; return APP.isWide() ? { lat: 20, lon: sl + 62, range: 2.6, heading: 0, tilt: 0, shiftY: 0, shiftX: 0 } : { lat: 24, lon: sl + 66, range: 2.85, heading: 0, tilt: 0, shiftY: 0.5, shiftX: 0 }; }; +GE.extraLabels = new Map(); + +/* ---------- wiring ---------- */ +APP.initShell = function () { + $$('.tab').forEach((b) => b.addEventListener('click', () => APP.setTab(b.dataset.tab))); + const openFollowed = () => GE.sheet.open(APP.following); + $('#acc').addEventListener('click', openFollowed); $('#accMini').addEventListener('click', openFollowed); + $('#btnSearch').addEventListener('click', () => GE.gui.openSearch()); + // minimize tab bar on scroll (feed-like views) + for (const id of ['#v-now', '#v-threads', '#v-you']) { + const v = $(id); let last = 0; + v.addEventListener('scroll', () => { + const y = v.scrollTop; const dy = y - last; last = y; + if (y < 40) APP.minimize(false); else if (dy > 4) APP.minimize(true); else if (dy < -6) APP.minimize(false); + const nb = v.querySelector('.navbar'); if (nb) nb.classList.toggle('on', y > (id === '#v-now' ? (APP.isWide() ? 90 : 250) : 60)); + clearTimeout(v._st); v._st = setTimeout(() => APP.minimize(false), 1400); + bus.emit('scroll', { id, y }); + }, { passive: true }); + } + addEventListener('resize', () => { APP.moveLens(); const w = APP.isWide(); if (w !== APP.wide) { APP.wide = w; APP.setTab(APP.tab, { force: true }); } }); + APP.wide = APP.isWide(); + setInterval(APP.renderAccessory, 1000); APP.renderAccessory(); + requestAnimationFrame(APP.moveLens); +}; diff --git a/mobile/vision/src/js/61-now.js b/mobile/vision/src/js/61-now.js new file mode 100644 index 000000000..86ebf1bf3 --- /dev/null +++ b/mobile/vision/src/js/61-now.js @@ -0,0 +1,162 @@ +/* Now: RedNote-style two-column masonry of living situation cards. Thumbnails are rendered from the + same WebGL globe in an offscreen pass (texture-crop fallback first, so there is never a blank). */ +const NOWV = (GE.now = { filter: 'all' }); +const TH = (GE.thumbs = { q: [], chains: new Map(), done: new Map() }); + +/* ---------- thumbnail renderer ---------- */ +Cam.viewFrom = function (spec, w, h) { + const save = { lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftX: Cam.shiftX, shiftY: Cam.shiftY, rig: Cam.rig }; + Object.assign(Cam, { shiftX: 0, shiftY: 0, heading: 0, tilt: 0 }, spec); Cam.rig = null; + const v = Cam.view(w, h); Object.assign(Cam, save); return v; +}; +TH.chainFor = function (w, h) { const k = w + 'x' + h; if (!TH.chains.has(k)) TH.chains.set(k, { chain: GL.chain(w, h), out: GL.target(w, h, { hdr: false }) }); return TH.chains.get(k); }; +TH.render = function (spec, w, h, o = {}) { return TH.renderView(Cam.viewFrom(spec, w, h), w, h, o); }; +TH.renderView = function (view, w, h, o = {}) { + const gl = GL.gl; const { chain, out } = TH.chainFor(w, h); + const W = GE.world; + const at = o.at ?? null; + const t = at != null ? (at - NOW) / 1000 : W.t; + const sun = at != null ? astro.sun(at) : W.sun; + const offsetH = at != null ? (at - Date.now()) / 3600000 : W.offsetH; + const saved = W.layers.map((l) => l.on); + if (o.layers) for (const id of o.layers) if (W.layerById[id]) W.layerById[id].on = true; + W.render(view, chain, out.fb, { t: o.t ?? t, sun: o.sun ?? sun, offsetH: o.offsetH ?? offsetH, simMs: o.simMs ?? at ?? W.simMs, lens: o.lens || 'cinematic', outW: w, outH: h, vpW: w, vpH: h, pxScale: o.px ?? 1.35, aurora: o.aurora, exposure: o.exposure ?? 1.08, grain: false, focus: o.focus, extraSprites: o.extraSprites, extraLines: o.extraLines, steps: 10, lineK: 1.25, cloudK: o.cloudK }); + W.layers.forEach((l, i) => (l.on = saved[i])); + const px = new Uint8Array(w * h * 4); + gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const cv = o.canvas || document.createElement('canvas'); cv.width = w; cv.height = h; + const cx = cv.getContext('2d'); const id = cx.createImageData(w, h); + for (let y = 0; y < h; y++) id.data.set(px.subarray((h - 1 - y) * w * 4, (h - y) * w * 4), y * w * 4); + cx.putImageData(id, 0, 0); + return cv; +}; +TH.situationSpec = (s) => s.thumbCam || GE.SIT.cameraFor(s); +TH.renderSituation = function (s, canvas) { + const w = canvas.width, hh = canvas.height; + const extras = GE.extrasFor ? GE.extrasFor(s.id) : null; + return TH.render(TH.situationSpec(s), w, hh, { at: s.time?.at ?? null, layers: s.layers, canvas, aurora: s.auroraK, extraSprites: extras?.sprites, extraLines: extras?.lines, focus: s.kind === 'recon' ? null : null }); +}; +TH.enqueue = function (fn) { TH.q.push(fn); }; +TH.step = function (budgetMs = 10) { + const t0 = performance.now(); let n = 0; + while (TH.q.length && (n === 0 || performance.now() - t0 < budgetMs)) { const fn = TH.q.shift(); try { fn(); } catch (e) { console.warn('thumb', e); } n++; } +}; +// texture-crop fallback, drawn instantly +TH.fallback = function (s, canvas) { + const W = GE.world; const cx = canvas.getContext('2d'); const w = canvas.width, hh = canvas.height; + const spec = TH.situationSpec(s) || { lat: s.lat, lon: s.lon, range: 0.3 }; + const spanDeg = clamp(spec.range * 70, 1.2, 120); + const img = W.img?.day; if (!img) { cx.fillStyle = '#0b1220'; cx.fillRect(0, 0, w, hh); return; } + const sx = ((spec.lon + 180) / 360) * img.width, sy = ((90 - spec.lat) / 180) * img.height; + const sw = (spanDeg / 360) * img.width, sh = sw * (hh / w); + cx.drawImage(img, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh); + const el = astro.elev(s.time?.at ?? Date.now(), s.lat, s.lon); + if (el < 4) { cx.globalAlpha = clamp((4 - el) / 12, 0, 0.92); cx.drawImage(W.img.night, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh); cx.globalAlpha = 1; } + const g = cx.createRadialGradient(w / 2, hh / 2, Math.min(w, hh) * 0.2, w / 2, hh / 2, Math.max(w, hh) * 0.75); + g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.6)'); cx.fillStyle = g; cx.fillRect(0, 0, w, hh); +}; + +/* ---------- feed ---------- */ +const famIcon = (f) => GE.FAM[f].icon; +function cardHTML(s) { + const c = GE.FAM[s.fam].color; + const [m, ml] = s.metric(); + const badge = s.d4 ? `4DThread` : s.live ? `Live` : `${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}`; + const right = s.thread ? `${['tamang', 'hydro', 'kora', 'geo'].map((k) => `${GE.PEOPLE[k].h[0].toUpperCase()}`).join('')}` : `${ic('eye', 'xs')}${fmt.k(s.watchers)}`; + return `
    +
    ${badge}
    ${ic(famIcon(s.fam), 'sm')}
    +
    ${m}
    ${ml}
    +
    ${esc(s.title)}
    ${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}${right}
    + ${s.ride ? `` : ''}
    `; +} +NOWV.render = function () { + const v = $('#v-now'); + const now = new Date(); + const kick = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }); + v.innerHTML = ` +
    +
    ${kick}·${fmt.hm(Date.now())} UTC
    +

    Now

    +
    ${GE.SIT.list.length} situations– aircraft– vessels${fmt.n(GE.SATS.length)} satellites
    +
    +
    + ${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => ``).join('')} +
    `; + $('#nowMe').addEventListener('click', () => APP.setTab('you')); + $$('#nowChips .chip').forEach((b) => b.addEventListener('click', () => { NOWV.filter = b.dataset.f; $$('#nowChips .chip').forEach((x) => x.classList.toggle('on', x === b)); NOWV.renderSections(); haptic(4); })); + NOWV.renderSections(); +}; +NOWV.renderSections = function () { + const host = $('#nowSecs'); host.innerHTML = ''; + for (const sec of GE.SIT.sections) { + if (NOWV.filter !== 'all' && NOWV.filter !== sec.id) continue; + const items = GE.SIT.list.filter((s) => s.section === sec.id); + const hd = h('div', { class: 'sec-h' }, h('h2', null, sec.title), h('span', null, sec.sub)); + const grid = h('div', { class: 'masonry' }); const cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols); + const hs = [0, 0]; + for (const s of items) { + const el = h('article', { class: 'card', 'data-id': s.id, tabindex: 0, role: 'button', 'aria-label': s.title }); el.innerHTML = cardHTML(s); + const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += s.aspect + 0.62; + el.addEventListener('click', (e) => { if (e.target.closest('[data-ride]')) { e.stopPropagation(); NOWV.open(s, el, { ride: true }); return; } NOWV.open(s, el); }); + el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); NOWV.open(s, el); } }); + const cv = el.querySelector('canvas'); + const cached = TH.done.get(s.id); + if (cached) cv.getContext('2d').drawImage(cached, 0, 0); + else { TH.fallback(s, cv); TH.enqueue(() => { const c2 = document.createElement('canvas'); c2.width = cv.width; c2.height = cv.height; TH.renderSituation(s, c2); TH.done.set(s.id, c2); $$(`.card[data-id="${s.id}"] canvas, .tcard[data-id="${s.id}"] canvas`).forEach((x) => { const xc = x.getContext('2d'); xc.drawImage(c2, 0, 0, x.width, x.height); }); }); } + } + host.append(hd, grid); + } +}; +NOWV.tick = function () { + const W = GE.world; + const c = W.counts; const air = (c.flights || 0) + (c.military || 0) + (c.ga || 0) + GE.HELI_CIRCUITS.length; + const a = $('#cAir'); if (a) a.textContent = fmt.n(air); + const sh = $('#cShip'); if (sh) sh.textContent = fmt.n(GE.SHIPS.length); + const ck = $('#nowClock'); if (ck) ck.textContent = fmt.hm(Date.now()) + ' UTC'; + for (const el of $$('.card[data-id]')) { + const s = GE.SIT.byId[el.dataset.id]; if (!s) continue; + const [m, ml] = s.metric(); const me = el.querySelector('[data-m]'), mle = el.querySelector('[data-ml]'); + if (me && me.innerHTML !== m) { me.innerHTML = m; } if (mle && mle.innerHTML !== ml) mle.innerHTML = ml; + const ag = el.querySelector('[data-age]'); if (ag) ag.textContent = `${s.source} · ${fmt.ago(Date.now() - s.updatedAt)}`; + } +}; + +/* ---------- hero morph: card → live globe ---------- */ +NOWV.open = function (s, cardEl, o = {}) { + const wide = APP.isWide(); + const thumb = cardEl?.querySelector('.thumb canvas'); + if (wide || !thumb || prefersReduced()) { GE.sheet.open(s.id, { ...o, fade: !wide }); return; } + const r = thumb.getBoundingClientRect(); const app = $('#app').getBoundingClientRect(); + const M = $('#morph'); M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv); + // target: the visible world above the sheet's medium detent + const tw = app.width, th = app.height * 0.57; + Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', borderRadius: '18px', opacity: '1', transition: 'none' }); + const sx = r.width / tw, sy = r.height / th; + M.style.transformOrigin = '0 0'; + M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${sx}, ${sy})`; + M.classList.add('on'); NOWV._morphCard = cardEl; + cardEl.style.visibility = 'hidden'; + GE.sheet.open(s.id, { ...o, morph: true }); + requestAnimationFrame(() => requestAnimationFrame(() => { + const k = GE.HASH && GE.HASH.slow ? +GE.HASH.slow : 1; M.style.transition = `transform ${0.62 * k}s cubic-bezier(.2,.9,.2,1), border-radius ${0.62 * k}s ease, opacity ${0.5 * k}s ease ${0.34 * k}s`; + M.style.transform = 'translate(0,0) scale(1,1)'; M.style.borderRadius = '0px'; M.style.opacity = '0'; + setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; }, 900 * k); + })); +}; +NOWV.foldBack = function (id) { + const cardEl = $(`#v-now .card[data-id="${id}"]`); const M = $('#morph'); + if (!cardEl || prefersReduced() || APP.isWide()) return false; + const thumb = cardEl.querySelector('.thumb canvas'); const app = $('#app').getBoundingClientRect(); + M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv); + const tw = app.width, th = app.height * 0.57; + Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', transformOrigin: '0 0', transition: 'none', transform: 'none', borderRadius: '0px', opacity: '0' }); + M.classList.add('on'); cardEl.style.visibility = 'hidden'; + requestAnimationFrame(() => { + const r = thumb.getBoundingClientRect(); + M.style.transition = 'transform .55s cubic-bezier(.3,.8,.2,1), border-radius .55s ease, opacity .18s ease'; + M.style.opacity = '1'; M.style.borderRadius = '18px'; + M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${r.width / tw}, ${r.height / th})`; + setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; cardEl.classList.add('flash'); setTimeout(() => cardEl.classList.remove('flash'), 1200); }, 600); + }); + return true; +}; diff --git a/mobile/vision/src/js/62-sheet.js b/mobile/vision/src/js/62-sheet.js new file mode 100644 index 000000000..a7763627f --- /dev/null +++ b/mobile/vision/src/js/62-sheet.js @@ -0,0 +1,196 @@ +/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always + wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with + uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */ +const SH = (GE.sheet = { id: null, detent: 'medium' }); +const sheetEl = () => $('#sheet'); +SH.heights = function () { + const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0; + return { peek: 112 + sab, medium: Math.round(H * 0.45), large: Math.round(H * 0.92), H }; +}; +SH.setDetent = function (d, animate = true) { + SH.detent = d; const el = sheetEl(); if (APP.isWide()) { el.classList.add('wopen'); return; } + const hs = SH.heights(); const full = el.offsetHeight || hs.H; const vis = hs[d]; + el.classList.toggle('drag', !animate); + el.style.transform = `translateY(${Math.max(0, full - vis)}px)`; + const sc = $('#sheetC'); sc.style.overflowY = d === 'large' ? 'auto' : 'hidden'; sc.style.touchAction = d === 'large' ? 'pan-y' : 'none'; + if (d !== 'large') sc.scrollTop = 0; + SH.targetShift = d === 'large' ? 0.75 : clamp(vis / hs.H, 0, 0.8); +}; +SH.open = function (id, o = {}) { + const s = GE.SIT.byId[id]; if (!s) return; + const W = GE.world; + SH.id = id; SH.sit = s; + if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); } + APP.setMode('situation'); + // layers & time window + SH._savedLayers = SH._savedLayers || W.layers.map((l) => l.on); + for (const lid of s.layers || []) if (W.layerById[lid]) W.layerById[lid].on = true; + bus.emit('layers', W.layers); + if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); } + W.auroraK = s.auroraK ?? 0.85; + GE.setExtras && GE.setExtras(s.id); + // camera + const cam = GE.SIT.cameraFor(s); + const hs = SH.heights(); + const shiftY = APP.isWide() ? 0 : hs.medium / hs.H; + SH.targetShift = shiftY; + Cam.autoSpin = 0; + if (o.morph) { Cam.set({ ...cam, shiftY, shiftX: 0 }); Cam.flyTo({ range: cam.range * 0.82, heading: cam.heading + 0.12 }, { duration: 2.6 }); } + else Cam.flyTo({ ...cam, shiftY, shiftX: 0 }); + W.focus = s.kind === 'recon' ? null : { lat: s.lat, lon: s.lon, k: 0.35 }; + SH.render(s); + const el = sheetEl(); + if (APP.isWide()) { el.classList.add('wopen'); } + else { el.classList.add('drag'); el.style.transform = 'translateY(100%)'; requestAnimationFrame(() => requestAnimationFrame(() => SH.setDetent(o.detent || 'medium'))); } + if (o.ride && s.ride) setTimeout(() => GE.ride.start({ kind: 'heli', id: 0 }), 650); + APP.updateChrome(); +}; +SH.close = function (silent) { + if (!SH.id) return; const id = SH.id; const W = GE.world; + const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)'; + SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); + if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); } + W.setWindow(null); W.setTime(null); W.auroraK = 0.85; + APP.setMode(''); + if (silent) return; + const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`); + if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); } + else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); } +}; + +/* drag */ +SH.initDrag = function () { + const el = sheetEl(); let st = null; + const begin = (e) => { + if (APP.isWide()) return; + const onGrab = e.target.closest('#grab'); const sc = $('#sheetC'); + if (!onGrab && SH.detent === 'large') return; + if (e.target.closest('button, a, input, textarea, .evid, .multi')) return; + st = { y: e.clientY, y0: e.clientY, t: performance.now(), base: new DOMMatrix(getComputedStyle(el).transform).m42, moved: 0, id: e.pointerId, samples: [] }; + el.setPointerCapture?.(e.pointerId); + }; + el.addEventListener('pointerdown', begin); + el.addEventListener('pointermove', (e) => { + if (!st || e.pointerId !== st.id) return; + const dy = e.clientY - st.y0; st.moved = Math.max(st.moved, Math.abs(dy)); + if (st.moved < 4) return; + el.classList.add('drag'); + const hs = SH.heights(); const full = el.offsetHeight; const minY = full - hs.large, maxY = full - hs.peek + 40; + let y = st.base + dy; if (y < minY) y = minY - Math.sqrt(minY - y) * 3; if (y > maxY) y = maxY + Math.sqrt(y - maxY) * 3; + el.style.transform = `translateY(${y}px)`; + st.samples.push({ t: performance.now(), y: e.clientY }); if (st.samples.length > 6) st.samples.shift(); + SH.targetShift = clamp((full - y) / hs.H, 0, 0.8); + }); + const end = (e) => { + if (!st || e.pointerId !== st.id) return; + const s0 = st.samples[0], s1 = st.samples[st.samples.length - 1]; + const v = s0 && s1 && s1.t > s0.t ? (s1.y - s0.y) / (s1.t - s0.t) : 0; // px/ms + const hs = SH.heights(); const full = el.offsetHeight; + const cur = new DOMMatrix(getComputedStyle(el).transform).m42; const visNow = full - cur + -v * 220; + if (st.moved >= 4) { + if (visNow < hs.peek * 0.6 && v > 0.6) { SH.close(); st = null; return; } + const ds = ['peek', 'medium', 'large']; let best = 'medium', bd = 1e9; for (const d of ds) { const dd = Math.abs(hs[d] - visNow); if (dd < bd) { bd = dd; best = d; } } + SH.setDetent(best); haptic(5); + } else if (e.target.closest('#grab')) { SH.setDetent(SH.detent === 'peek' ? 'medium' : SH.detent === 'medium' ? 'large' : 'medium'); } + st = null; + }; + el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end); + bus.on('globe-touch', () => { if (SH.id && SH.detent !== 'peek' && !APP.isWide()) SH.setDetent('peek'); }); + $('#back').addEventListener('click', () => { if (APP.mode === 'situation') SH.close(); }); + $('#sitFollow').addEventListener('click', () => { if (!SH.id) return; APP.following = SH.id === 'cyclone' || !SH.sit.accessory ? 'cyclone' : SH.id; APP.toast(`${ic('bell', 'sm')} Following · alerts within your radius`); haptic(10); }); + $('#sitShare').addEventListener('click', () => APP.toast(`${ic('link', 'sm')} Link copied · opens at this place & time`)); +}; + +/* ---------- content ---------- */ +function sparkSVG(sr, w) { + const hgt = 46, pad = 3; const d = sr.data; const n = d.length; + const band = sr.band || 0; + let mn = Math.min(...d) - band, mx = Math.max(...d) + band; if (mx - mn < 1e-6) { mx += 1; mn -= 1; } + const X = (i) => pad + (i / (n - 1)) * (w - pad * 2 - 34), Y = (v) => pad + 6 + (1 - (v - mn) / (mx - mn)) * (hgt - pad * 2 - 12); + const split = sr.split ?? n - 1; + const path = (a, b) => d.slice(a, b + 1).map((v, k) => `${k ? 'L' : 'M'}${X(a + k).toFixed(1)} ${Y(v).toFixed(1)}`).join(''); + let s = ''; + if (band) { const up = d.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v + band * (i > split ? 1 + (i - split) * 0.35 : 1)).toFixed(1)}`).join(''); const lo = d.map((v, i) => [X(i), Y(v - band * (i > split ? 1 + (i - split) * 0.35 : 1))]).reverse().map(([x, y]) => `L${x.toFixed(1)} ${y.toFixed(1)}`).join(''); s += ``; } + s += ``; + if (split < n - 1) s += ``; + const iMax = d.indexOf(Math.max(...d)), iMin = d.indexOf(Math.min(...d)); + const f = sr.fmt || ((v) => v); + s += `${f(d[iMax])}`; + if (iMin !== iMax) s += `${f(d[iMin])}`; + const ln = split; s += `${f(d[ln])}`; + if (split < n - 1) s += `forecast`; + return `${s}`; +} +GE.sparkSVG = sparkSVG; +function barsSVG(items, w) { + const hgt = items.length * 22; let s = ''; + items.forEach(([k, v], i) => { const y = i * 22; s += `${k}${Math.round(v * 100)}`; }); + return `${s}`; +} +SH.render = function (s) { + const c = GE.FAM[s.fam].color; const el = $('#sheetC'); + const ms = s.metrics(); + const live = s.live ? `Live` : s.d4 ? `4D` : `Replay`; + const acts = [ + ``, + s.ride ? `` : ``, + ``, + ``, + ].join(''); + const b = s.brief || {}; + el.innerHTML = ` +
    ${GE.FAM[s.fam].name}${live}${fmt.ago(Date.now() - s.updatedAt)}
    +

    ${esc(s.title)}

    ${esc(s.place)} · ${fmt.ll(s.lat, s.lon, 2)}
    +
    ${ms.map((m) => `
    ${m.v}${m.u}
    ${m.l}
    ${m.s}
    `).join('')}
    +
    ${acts}
    +
    + ${b.lede ? `

    ${esc(b.lede)}

    ` : ''} + ${b.series?.length ? `

    Signals

    ` : ''} + ${b.multiples ? `

    ${esc(b.multiples.label)}

    ` : ''} +

    Evidence

    +

    Sources

    ${(b.sources || []).map(([n, a, st]) => `
    ${esc(n)}${esc(a)}
    `).join('')} +
    Sun, terminator & timecomputed
    + ${s.thread ? `
    ` : ''} +

    Concept · illustrative data. People, handles, posts and claims shown here are fictional.

    +
    `; + const w = Math.max(240, (APP.isWide() ? $('#sheet').clientWidth : $('#app').clientWidth) - 36); + if (b.series?.length) $('#shSeries').innerHTML = b.series.map((sr) => `
    ${esc(sr.label)}${(sr.fmt || ((v) => v))(sr.data[sr.split ?? sr.data.length - 1])} ${esc(sr.unit)}${sparkSVG(sr, w)}
    ${esc(sr.src)}${sr.band ? '± band = uncertainty' : ''}
    `).join(''); + if (b.multiples) { + const mh = $('#shMulti'); + if (b.multiples.kind === 'bars') { mh.style.gridTemplateColumns = '1fr'; mh.innerHTML = barsSVG(b.multiples.items, w); } + else for (const it of b.multiples.items) { + const fig = h('figure'); const cv = h('canvas', { width: 180, height: 180 }); fig.append(cv, h('figcaption', { html: it.cap })); mh.append(fig); + const spec = it.cam || { lat: s.lat, lon: s.lon, range: 0.2, heading: 0, tilt: 0.2 }; + TH.fallback(s, cv); + TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 180, 180, { at: Date.now() + it.h * 3600000, layers: s.layers, canvas: cv, px: 1.0 }); }); + } + } + // evidence strip — each tile is rendered from the globe near the place (signed captures, illustrative) + const ev = $('#shEvid'); const people = ['tamang', 'hydro', 'sky', 'ais', 'geo', 'crowd']; + const cam0 = GE.SIT.cameraFor(s) || { lat: s.lat, lon: s.lon, range: 0.01 }; + for (let i = 0; i < 5; i++) { + const p = GE.PEOPLE[people[(i + s.id.length) % people.length]]; + const cv = h('canvas', { width: 232, height: 168 }); const im = h('div', { class: 'im' }); im.append(cv); cv.style.cssText = 'width:100%;height:100%;display:block'; + const near = cam0.range < 0.02; + const spec = { lat: cam0.lat + (hash01(i + 3) - 0.5) * (near ? 0.01 : 0.6), lon: cam0.lon + (hash01(i + 9) - 0.5) * (near ? 0.012 : 0.6), range: near ? cam0.range * (0.35 + i * 0.12) : 0.004 + i * 0.003, heading: i * 1.3 + 0.4, tilt: 1.25 }; + TH.fallback({ ...s, thumbCam: spec }, cv); + TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 232, 168, { at: s.time?.at ?? null, layers: s.layers, canvas: cv, px: 1.0, extraSprites: GE.world.activeExtras?.sprites, extraLines: GE.world.activeExtras?.lines }); }); + im.insertAdjacentHTML('beforeend', ``); + const card = h('div', { class: 'ev' }, im, h('div', { class: 'cap', html: `@${p.h}${['Signed · IMU ✓ GPS ±5 m', 'Signed · horizon ✓', 'Signed · 12 s clip', 'Signed · heading ✓', 'Signed · audio sync'][i]}
    ${fmt.ago((i + 1) * 1000 * 60 * (7 + i * 13))}` })); + ev.append(card); + } + el.querySelectorAll('[data-a]').forEach((btn) => btn.addEventListener('click', () => SH.action(btn.dataset.a, s))); +}; +SH.tick = function () { + if (!SH.id) return; const s = SH.sit; const ms = s.metrics(); const host = $('#shMetrics'); if (!host) return; + host.querySelectorAll('.metric').forEach((m, i) => { const v = m.querySelector('.v'); const html = `${ms[i].v}${ms[i].u}`; if (v.innerHTML !== html) v.innerHTML = html; }); + const a = $('[data-sage]'); if (a) a.textContent = fmt.ago(Date.now() - s.updatedAt); +}; +SH.action = function (a, s) { + haptic(6); + if (a === 'follow') { APP.following = s.accessory ? s.id : 'cyclone'; APP.toast(`${ic('bell', 'sm')} Following ${esc(s.title.split(' — ')[0])}`); } + if (a === 'ride') GE.ride.start({ kind: 'heli', id: 0 }); + if (a === 'replay') { GE.world.setTime(Date.now() - 6 * 3600000); APP.toast(`${ic('clock', 'sm')} Replaying the last 6 h — drag the ribbon`); SH.setDetent('peek'); } + if (a === 'eye') { const off = geo.dest(s.lat, s.lon, 0.7, 0.8); Cam.flyTo({ lat: off[0], lon: off[1], range: 0.0006, tilt: 1.1 }); SH.setDetent('peek'); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera 0.8 km · illustrative`); } + if (a === 'thread') { if (s.thread) { SH.close(true); APP.setTab('threads'); GE.threads.openWs(s.thread); } else APP.toast(`${ic('threads', 'sm')} Thread started · invite people to add observations`); } +}; diff --git a/mobile/vision/src/js/63-globe-ui.js b/mobile/vision/src/js/63-globe-ui.js new file mode 100644 index 000000000..c40754a3e --- /dev/null +++ b/mobile/vision/src/js/63-globe-ui.js @@ -0,0 +1,221 @@ +/* Globe chrome: lens dock (8 families), Control-Center layer grid (long-press = solo), visual lenses, + haptic time ribbon (−72 h ← NOW → +120 h) with event pips, pick card, search. */ +const GUI = (GE.gui = {}); +const FAMS = Object.keys(GE.FAM); +const LENSES = [ + ['cinematic', 'Cinematic', 'linear-gradient(135deg,#0B2A55,#1E6FB8 45%,#F2B36B 80%,#2a1a10)'], + ['clear', 'Clear', 'linear-gradient(135deg,#3B6EA8,#8FB9E3 50%,#C9D9A4)'], + ['night', 'Night', 'radial-gradient(circle at 60% 40%,#FFC46B 0,#7A4A12 8%,#0B1426 30%,#04070D)'], + ['thermal', 'Thermal', 'linear-gradient(90deg,#08001A,#5A0F8C 30%,#E3263F 55%,#FF9F00 78%,#FFFBD6)'], + ['nvg', 'NVG', 'radial-gradient(circle,#B8FFB0 0,#2EA043 35%,#05200C 75%)'], +]; +GUI.famCount = (f) => GE.world.layers.filter((l) => l.fam === f && l.on).reduce((s, l) => s + (l.count || 0), 0); + +/* ---------- dock ---------- */ +GUI.renderDock = function () { + const d = $('#dock'); const W = GE.world; + d.innerHTML = FAMS.map((f) => { const F = GE.FAM[f]; const on = W.layers.some((l) => l.fam === f && l.on); return ``; }).join(''); + $$('#dock .fam').forEach((b) => { + let tm = null, long = false; + b.addEventListener('pointerdown', () => { long = false; tm = setTimeout(() => { long = true; GUI.soloFam(b.dataset.f); }, 520); }); + const clr = () => clearTimeout(tm); + b.addEventListener('pointerup', clr); b.addEventListener('pointerleave', clr); b.addEventListener('pointercancel', clr); + b.addEventListener('click', () => { if (long) return; GUI.openCC(b.dataset.f); }); + b.addEventListener('contextmenu', (e) => e.preventDefault()); + }); +}; +GUI.soloFam = function (f) { + const W = GE.world; + if (GUI.solo === f) { if (GUI._preSolo) W.layers.forEach((l, i) => (l.on = GUI._preSolo[i])); GUI.solo = null; APP.toast(`Restored all families`); } + else { if (!GUI.solo) GUI._preSolo = W.layers.map((l) => l.on); GUI.solo = f; W.layers.forEach((l) => (l.on = l.fam === f ? (l.on || ['flights', 'ais', 'sats', 'quakes', 'fires', 'cyclones', 'cctv', 'holo', 'venues', 'clouds', 'radar', 'gnss'].includes(l.id)) : l.id === 'clouds')); APP.toast(`● Soloed ${GE.FAM[f].name} · long-press to restore`); } + haptic([8, 30, 8]); bus.emit('layers', W.layers); +}; + +/* ---------- Control Center grid ---------- */ +GUI.ccHTML = function (inPane) { + const W = GE.world; + const lens = `
    ${LENSES.map(([id, n, bg]) => ``).join('')}
    `; + const fams = FAMS.map((f) => { const F = GE.FAM[f]; const ls = W.layers.filter((l) => l.fam === f); + return `
    ${F.name}${ls.length}
    ${ls.map((l) => ``).join('')}
    `; }).join(''); + return `

    Layers

    ${W.layers.length} live layers in 8 families · long-press a family to solo

    ${inPane ? '' : ``}
    Lens
    ${lens}${fams}`; +}; +GUI.bindCC = function (root) { + const W = GE.world; + root.querySelectorAll('[data-l]').forEach((b) => b.addEventListener('click', () => { const l = W.layerById[b.dataset.l]; l.on = !l.on; b.classList.toggle('on', l.on); haptic(4); bus.emit('layers', W.layers); })); + root.querySelectorAll('[data-lens]').forEach((b) => b.addEventListener('click', () => { W.setLens(b.dataset.lens); root.querySelectorAll('[data-lens]').forEach((x) => x.classList.toggle('on', x === b)); haptic(6); })); + root.querySelectorAll('[data-solo]').forEach((b) => b.addEventListener('click', () => { GUI.soloFam(b.dataset.solo); GUI.refreshCC(); })); + root.querySelectorAll('[data-fh]').forEach((hd) => { let tm; hd.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; tm = setTimeout(() => { GUI.soloFam(hd.dataset.fh); GUI.refreshCC(); }, 520); }); const c = () => clearTimeout(tm); hd.addEventListener('pointerup', c); hd.addEventListener('pointerleave', c); }); + root.querySelector('#ccX')?.addEventListener('click', GUI.closeCC); +}; +GUI.openCC = function (fam) { + if (APP.isWide()) { if (APP.tab !== 'globe') APP.setTab('globe'); GUI.renderLayersPane(); const t = $('#ccf-' + fam); if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } + const cc = $('#cc'); cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.classList.add('on'); + if (fam) requestAnimationFrame(() => { const t = $('#ccf-' + fam); if (t) cc.scrollTop = t.offsetTop - 60; }); + haptic(6); +}; +GUI.refreshCC = function () { const cc = $('#cc'); if (cc.classList.contains('on')) { const st = cc.scrollTop; cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.scrollTop = st; } if (APP.isWide() && APP.tab === 'globe') GUI.renderLayersPane(); }; +GUI.closeCC = () => $('#cc').classList.remove('on'); +GUI.renderLayersPane = function () { const v = $('#v-layers'); const st = v.scrollTop; v.innerHTML = `
    ${GUI.ccHTML(true)}
    `; GUI.bindCC(v); v.scrollTop = st; }; + +/* ---------- ribbon ---------- */ +const RB = { span: 30, vel: 0, drag: null }; +GUI.initRibbon = function () { + const el = $('#ribbon'), cv = el.querySelector('canvas'); + const W = GE.world; + const hpp = () => RB.span / Math.max(1, el.clientWidth); + el.addEventListener('pointerdown', (e) => { if (e.target.closest('#rbNow')) return; el.setPointerCapture(e.pointerId); RB.drag = { x: e.clientX, t: performance.now(), lastHour: Math.floor(W.offsetH) }; RB.vel = 0; Cam.lastInput = performance.now(); }); + el.addEventListener('pointermove', (e) => { + if (!RB.drag) return; const now = performance.now(); const dx = e.clientX - RB.drag.x; const dt = Math.max(1, now - RB.drag.t); + const speed = Math.abs(dx) / dt; // px/ms + const k = clamp(0.22 + speed * 1.1, 0.22, 2.6); // slow → minutes, fast → hours + const dh = -dx * hpp() * k; + GUI.scrubTo(W.offsetH + dh); + RB.vel = dh / dt * 16; RB.drag.x = e.clientX; RB.drag.t = now; + const hr = Math.floor(W.offsetH); if (hr !== RB.drag.lastHour) { RB.drag.lastHour = hr; haptic(3); } + }); + const up = () => { RB.drag = null; }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); + $('#rbNow').addEventListener('click', () => GUI.snapNow()); + GUI.drawRibbon(); +}; +GUI.scrubTo = function (h) { + const W = GE.world; const win = W.getWindow(); const nowMs = Date.now(); + const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000; + W.setTime(nowMs + clamp(h, lo, hi) * 3600000); +}; +GUI.snapNow = function () { + const W = GE.world; const win = W.getWindow(); + if (W.window) { GUI.scrubTo(((win.start + win.end) / 2 - Date.now()) / 3600000); return; } + const from = W.offsetH; const t0 = performance.now(); haptic(12); + const anim = () => { const u = clamp((performance.now() - t0) / 600, 0, 1); const e = ease.outBack(u); const v = from * (1 - e); if (u >= 1) { W.setTime(null); return; } W.setTime(Date.now() + v * 3600000); requestAnimationFrame(anim); }; + anim(); +}; +GUI.ribbonFrame = function () { + if (!RB.drag && Math.abs(RB.vel) > 1e-4) { GUI.scrubTo(GE.world.offsetH + RB.vel); RB.vel *= 0.92; } + GUI.drawRibbon(); +}; +GUI.drawRibbon = function () { + const el = $('#ribbon'); if (!el || !el.offsetWidth) return; const cv = el.querySelector('canvas'); + const W = GE.world; const dpr = Math.min(devicePixelRatio || 1, 2); + const w = el.clientWidth, hh = el.clientHeight; + if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(hh * dpr); } + const cx = cv.getContext('2d'); cx.setTransform(dpr, 0, 0, dpr, 0, 0); cx.clearRect(0, 0, w, hh); + const win = W.getWindow(); const nowMs = Date.now(); + RB.span = W.window ? Math.max(4, (win.end - win.start) / 3600000 * 0.7) : 30; + const cur = W.offsetH; const pxh = w / RB.span; const X = (hr) => w / 2 + (hr - cur) * pxh; + const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000; + // past / forecast regions + const xNow = X(0); + if (!W.window) { + cx.fillStyle = 'rgba(138,180,248,.08)'; cx.fillRect(Math.max(0, xNow), 0, Math.max(0, w - Math.max(0, xNow)), hh); + cx.save(); cx.beginPath(); cx.rect(Math.max(0, xNow), 0, w, hh); cx.clip(); cx.strokeStyle = 'rgba(138,180,248,.10)'; cx.lineWidth = 1; for (let x = Math.floor(xNow) - hh; x < w; x += 7) { cx.beginPath(); cx.moveTo(x, hh); cx.lineTo(x + hh, 0); cx.stroke(); } cx.restore(); + } + // out-of-window shade + cx.fillStyle = 'rgba(0,0,0,.35)'; if (X(lo) > 0) cx.fillRect(0, 0, X(lo), hh); if (X(hi) < w) cx.fillRect(X(hi), 0, w - X(hi), hh); + // ticks + const h0 = Math.floor(cur - RB.span / 2) - 1, h1 = Math.ceil(cur + RB.span / 2) + 1; + cx.font = '600 10px ' + getComputedStyle(document.body).fontFamily; cx.textAlign = 'center'; + for (let k = h0; k <= h1; k++) { + const x = X(k); const abs = new Date(nowMs + k * 3600000); const hrUTC = abs.getUTCHours(); + const major = hrUTC % 6 === 0, day = hrUTC === 0; + cx.strokeStyle = day ? 'rgba(255,255,255,.55)' : major ? 'rgba(255,255,255,.32)' : 'rgba(255,255,255,.14)'; + cx.lineWidth = day ? 1.4 : 1; + const th = day ? 18 : major ? 12 : 6; cx.beginPath(); cx.moveTo(x, hh - 6); cx.lineTo(x, hh - 6 - th); cx.stroke(); + if (major && pxh * 6 > 34) { cx.fillStyle = day ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)'; cx.fillText(day ? abs.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' }) : String(hrUTC).padStart(2, '0'), x, hh - 26 - (day ? 2 : 0)); } + // minute ticks when zoomed (slow scrub) + } + // NOW marker + if (!W.window && xNow > -2 && xNow < w + 2) { cx.fillStyle = '#FF453A'; cx.fillRect(xNow - 1, 4, 2, hh - 8); } + // event pips + const pips = GUI.pips(); + for (const p of pips) { const x = X(p.h); if (x < -6 || x > w + 6) continue; cx.fillStyle = p.c; cx.beginPath(); cx.arc(x, hh - 6, 3.2, 0, TAU); cx.fill(); cx.shadowColor = p.c; cx.shadowBlur = 8; cx.beginPath(); cx.arc(x, hh - 6, 2, 0, TAU); cx.fill(); cx.shadowBlur = 0; if (Math.abs(x - w / 2) < 60) { cx.fillStyle = 'rgba(255,255,255,.7)'; cx.font = '600 9.5px ' + getComputedStyle(document.body).fontFamily; cx.fillText(p.l, x, hh - 14); } } + // readout + const simMs = nowMs + cur * 3600000; + $('#rbTime').textContent = fmt.utc(simMs); + $('#rbRel').textContent = W.window ? '' : W.live ? '' : fmt.rel(cur); + el.classList.toggle('future', cur > 0.02 && !W.window); + el.classList.toggle('breath', W.live && !prefersReduced()); + const nb = $('#rbNow'); nb.classList.toggle('live', W.live); nb.innerHTML = W.window ? `${ic('clock', 'xs')}EVENT` : W.live ? 'LIVE' : 'NOW'; + const stt = $('#statusTxt'); if (stt) { stt.textContent = W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'; $('#statusTime').textContent = fmt.utc(simMs, false) + ' UTC'; $('#status .dot').style.background = W.live ? '' : cur > 0 ? 'var(--wx)' : 'var(--gold)'; } +}; +GUI.pips = function () { + const nowMs = Date.now(); const W = GE.world; + if (W.window && GE.threads?.ws?.beats) return GE.threads.ws.beats.map((b) => ({ h: (b.at - nowMs) / 3600000, c: '#E9C46A', l: b.t })); + return [{ h: GE.BIGQ.t / 3600, c: '#FF6A3D', l: 'M6.4' }, { h: 2.24, c: '#C9A7FF', l: 'Launch' }, { h: 40, c: '#FF6A3D', l: 'Landfall' }, { h: (GE.SIT.byId.aurora.time.at - nowMs) / 3600000, c: '#5CF2A0', l: 'Aurora' }, { h: -30, c: '#FF6A3D', l: 'Fire run' }, { h: (SIT._iss?.start - nowMs) / 3600000 || 3, c: '#C9A7FF', l: 'ISS' }]; +}; + +/* ---------- pick card ---------- */ +GUI.showPick = function (p) { + const el = $('#pick'); const W = GE.world; + if (!p) { el.classList.remove('on'); W.sel = null; W.selFlight = -1; W.selSat = -1; W.selRoute = null; return; } + const famOf = { flight: 'sky', heli: 'sky', ship: 'sea', sat: 'orbit', quake: 'haz', holo: 'eyes', situation: 'haz' }[p.kind] || 'sky'; + const icn = { flight: 'airplane', heli: 'helicopter', ship: 'ferry', sat: 'iss', quake: 'wave', holo: 'seal', situation: 'radiowaves' }[p.kind]; + W.sel = { ...p, v: p.kind === 'flight' ? v3.scale(geo.vec(p.lat, p.lon), 1 + (p.altKm * W.altX) / EARTH_KM) : geo.vec(p.lat, p.lon, p.altKm || 0.1) }; + W.selFlight = p.kind === 'flight' ? p.id : -1; W.selSat = p.kind === 'sat' ? p.id : -1; + if (p.kind === 'flight') { const m = p.meta; W.selRoute = (() => { const a = m.from, b = m.to; const pts = []; for (let i = 0; i <= 64; i++) { const t = i / 64; pts.push(v3.scale(geo.slerp(a.v, b.v, t), 1 + 0.002)); } return pts; })(); } else W.selRoute = null; + let kv = ''; + if (p.kind === 'flight') kv = `
    ${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)}ft
    ${Math.round(p.meta.f.speed / 1.852)}kt
    ${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°hdg
    ${Math.round(p.meta.prog * 100)}%route
    `; + else if (p.kind === 'ship') kv = `
    ${fmt.ll(p.lat, p.lon, 2)}position
    `; + else if (p.kind === 'heli') kv = `
    ${Math.round(p.altKm * 3281)}ft AGL
    ${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°hdg
    `; + else kv = `
    ${fmt.ll(p.lat, p.lon, 2)}position
    `; + const canRide = p.kind === 'flight' || p.kind === 'heli'; + el.style.setProperty('--c', GE.FAM[famOf].color); + el.innerHTML = `
    ${ic(icn)}
    ${esc(p.title)}${esc(p.subtitle || '')}
    ${kv}
    +
    `; + el.classList.add('on'); + el.querySelector('.x').addEventListener('click', () => GUI.showPick(null)); + el.querySelectorAll('[data-p]').forEach((b) => b.addEventListener('click', () => { + const a = b.dataset.p; haptic(6); + if (a === 'ride' && canRide) { GUI.showPick(null); GE.ride.start(p); } + if (a === 'follow') APP.toast(`${ic('bell', 'sm')} Following ${esc(p.title)}`); + if (a === 'eye') { Cam.flyTo({ lat: p.lat, lon: p.lon, range: Math.min(Cam.range, 0.004), tilt: 1.0 }); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera ${(0.6 + hash01(p.id || 1) * 3).toFixed(1)} km · illustrative`); } + if (a === 'thread') APP.toast(`${ic('threads', 'sm')} Thread started for ${esc(p.title)}`); + })); +}; +bus.on('tap', ({ x, y }) => { + if (APP.mode === 'ride' || APP.tab === 'capture') return; + if (APP.tab !== 'globe' && !APP.isWide()) return; + const v = GE.mainView; if (!v) return; + const p = GE.world.pick(v, x, y, 28); + if (p && p.kind === 'situation') { GE.sheet.open(p.id, { fromGlobe: true }); return; } + GUI.showPick(p); if (p) haptic(5); +}); + +/* ---------- search ---------- */ +GUI.openSearch = function () { + const el = $('#search'); + el.innerHTML = `
    ${ic('search')}
    `; + el.classList.add('on'); + const q = $('#sq'); const run = () => GUI.searchResults(q.value.trim()); + q.addEventListener('input', run); run(); setTimeout(() => q.focus(), 60); + $('#sx').addEventListener('click', () => el.classList.remove('on')); +}; +GUI.searchResults = function (q) { + const res = $('#sres'); const ql = q.toLowerCase(); const W = GE.world; + const groups = []; + const sits = GE.SIT.list.filter((s) => !ql || (s.title + s.place).toLowerCase().includes(ql)).slice(0, ql ? 6 : 4); + groups.push(['Situations', sits.map((s) => ({ c: GE.FAM[s.fam].color, i: GE.FAM[s.fam].icon, t: s.title, s: s.place, go: () => { $('#search').classList.remove('on'); GE.sheet.open(s.id, { fromGlobe: true }); } }))]); + const places = [...GE.CITY_V.map((c) => ({ ...c, k: 'city' })), ...GE.AP.map((a) => ({ name: `${a.iata} · ${a.city}`, lat: a.lat, lon: a.lon, k: 'ap' }))].filter((p) => ql && p.name.toLowerCase().includes(ql)).slice(0, 5); + groups.push(['Places', places.map((p) => ({ c: 'var(--ground)', i: p.k === 'ap' ? 'airplane' : 'location', t: p.name, s: fmt.ll(p.lat, p.lon, 2), go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: p.k === 'ap' ? 0.006 : 0.012, tilt: 0.9, heading: 0.3 }); } }))]); + const lays = W.layers.filter((l) => ql && l.name.toLowerCase().includes(ql)).slice(0, 4); + groups.push(['Layers', lays.map((l) => ({ c: GE.FAM[l.fam].color, i: l.icon, t: l.name, s: `${GE.FAM[l.fam].name} · ${l.on ? 'on' : 'off'} · ${fmt.n(l.count)}`, go: () => { l.on = true; bus.emit('layers'); $('#search').classList.remove('on'); GUI.openCC(l.fam); } }))]); + if (ql.length >= 2) { + const fl = []; for (let i = 0; i < GE.FLIGHTS.length && fl.length < 4; i++) { const f = GE.FLIGHTS[i]; if (f.cs.toLowerCase().includes(ql)) { const p = GE.flightAt(f, W.t); if (p) fl.push({ f, i, p }); } } + groups.push(['Contacts', fl.map(({ f, i, p }) => ({ c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${p.from.iata} → ${p.to.iata}`, go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: 0.05, tilt: 0.6 }); GUI.showPick({ kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog } }); } }))]); + } + res.innerHTML = ''; const frag = document.createDocumentFragment(); + for (const [title, items] of groups) { if (!items.length) continue; frag.append(h('div', { class: 'grpH' }, title)); for (const it of items) { const b = h('button', { class: 'it', html: `${ic(it.i, 'sm')}${esc(it.t)}${esc(it.s)}` }); b.addEventListener('click', it.go); frag.append(b); } } + if (!q) frag.prepend(h('div', { class: 'grpH', style: { marginTop: 0 } }, 'Try “typhoon”, “ATL”, “Kathmandu”, “fires”, “AXR”')); + res.append(frag); +}; + +GUI.init = function () { + GUI.renderDock(); GUI.initRibbon(); + bus.on('layers', () => { GUI.renderDock(); GUI.refreshCCSoft(); }); + GUI.refreshCCSoft = () => { const cc = $('#cc'); if (cc.classList.contains('on')) cc.querySelectorAll('[data-l]').forEach((b) => { const l = GE.world.layerById[b.dataset.l]; b.classList.toggle('on', l.on); const n = b.querySelector('.tc .num'); if (n) n.textContent = l.count ? fmt.n(l.count) : '—'; }); }; + $('#btnLayers').addEventListener('click', () => GUI.openCC()); + $('#btnLens').addEventListener('click', () => { const ids = LENSES.map((l) => l[0]); const i = (ids.indexOf(GE.world.lens) + 1) % ids.length; GE.world.setLens(ids[i]); APP.toast(`${ic('sparkles', 'sm')} ${LENSES[i][1]} lens`); $('#btnLens').classList.toggle('on', ids[i] !== 'cinematic'); }); + $('#btnHome').addEventListener('click', () => { GUI.showPick(null); Cam.flyTo({ range: 2.6, tilt: 0, heading: 0, shiftY: APP.isWide() ? 0 : 0.06 }); }); + $('#btnCompass').addEventListener('click', () => Cam.flyTo({ heading: 0, tilt: 0 }, { duration: 0.8 })); + $('#status').addEventListener('click', () => GUI.snapNow()); +}; diff --git a/mobile/vision/src/js/64-ride.js b/mobile/vision/src/js/64-ride.js new file mode 100644 index 000000000..9d3dd0d9b --- /dev/null +++ b/mobile/vision/src/js/64-ride.js @@ -0,0 +1,93 @@ +/* Ride: drop into a cockpit-follow camera on a helicopter circuit or an airliner, with a minimal HUD + and a social reaction layer (fictional handles) — the communication aspect of watching together. */ +const RIDE = (GE.ride = { on: false }); +const CHAT_HELI = [ + ['rotorwash_ret', 'I flew that exact circuit 40 times. Downwind is where your instructor goes quiet.'], + ['kora_maps', 'Watching from Lisbon. The stagefields look like a chessboard from here'], + ['skywatch_ana', 'Look at the spacing — four aircraft in the pattern and nobody bunching up'], + ['rotorwash_ret', 'Turning base now. Watch the airspeed bleed off'], + ['hydro_nerd', 'is this live?? the shadows match my window right now'], + ['geodesy_dan', 'MLAT puts them at ±15 m here. Cool to see it smooth'], + ['ais_owl', 'Came from the Suez queue thread. This is so much calmer'], + ['rotorwash_ret', 'Short final. Hover check. Then do it again. And again.'], +]; +const CHAT_JET = [ + ['skywatch_ana', 'Cruise is the best seat in the house. Look at the terminator out the left side'], + ['kora_maps', 'You can see the cloud deck the forecast promised'], + ['geodesy_dan', 'Ground speed is up — jet stream tailwind'], + ['ais_owl', 'There’s a whole convoy of ships under us right now'], +]; +RIDE.start = function (p) { + const W = GE.world; + if (APP.mode === 'situation') { GE.sheet.close(true); } + RIDE.p = p; RIDE.on = true; RIDE.yaw = 0; RIDE.pitch = 0; RIDE.zoom = 1; + if (APP.tab !== 'globe') { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.pane .view').forEach((v) => v.classList.remove('on')); } + APP.setMode('ride'); + W.layerById.helis.on = true; + const isHeli = p.kind === 'heli' || p.kind === 'situation'; + const hc = isHeli ? GE.HELI_CIRCUITS[p.id || 0] : null; const fl = !isHeli ? GE.FLIGHTS[p.id] : null; + RIDE.hc = hc; RIDE.fl = fl; + const state = () => { + const t = W.t; + if (hc) { const a = GE.heliAt(hc, t), b = GE.heliAt(hc, t + 1.5); const hd = geo.bearing(a.lat, a.lon, b.lat, b.lon); const c = GE.heliAt(hc, t - 1.5); const hd0 = geo.bearing(c.lat, c.lon, a.lat, a.lon); let turn = hd - hd0; if (turn > Math.PI) turn -= TAU; if (turn < -Math.PI) turn += TAU; return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: hd, roll: clamp(turn * 6, -0.45, 0.45), spd: a.spd, vs: (b.altKm - c.altKm) / 3 * 3281 * 60 }; } + const a = GE.flightAt(fl, t) || GE.flightAt(fl, t + 600); const b = GE.flightAt(fl, t + 4) || a; + return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: geo.bearing(a.lat, a.lon, b.lat, b.lon), roll: 0, spd: fl.speed / 1.852, vs: (b.altKm - a.altKm) / 4 * 3281 * 60 }; + }; + RIDE.state = state; + const rig = { + blendTime: 1.8, + pose() { const s = state(); RIDE.s = s; const pitch = (hc ? -0.16 : -0.05) + RIDE.pitch; return Cam.pose(s.lat, s.lon, s.alt + (hc ? 0.003 : 0.002), s.hdg + RIDE.yaw, pitch, s.roll * 0.6, (hc ? 64 : 58) * D2R / RIDE.zoom); }, + look(dx, dy) { RIDE.yaw -= dx * 0.004; RIDE.pitch = clamp(RIDE.pitch - dy * 0.003, -0.9, 0.5); }, + zoom(r) { RIDE.zoom = clamp(RIDE.zoom / r, 0.6, 3); }, + }; + Cam.setRig(rig); + RIDE.renderUI(isHeli); + RIDE.chatI = 0; clearInterval(RIDE._ci); RIDE._ci = setInterval(() => RIDE.nextChat(), 2600); setTimeout(() => RIDE.nextChat(), 900); + clearInterval(RIDE._ri); RIDE._ri = setInterval(() => { if (Math.random() < 0.7) RIDE.heart(); }, 700); + haptic([10, 40, 10]); +}; +RIDE.renderUI = function (isHeli) { + const el = $('#ride'); const nm = isHeli ? RIDE.hc.cs : RIDE.fl.cs; const tp = isHeli ? `${RIDE.hc.type} · ${RIDE.hc.field || 'orbit'} circuit` : `${RIDE.fl.type} · ${GE.flightAt(RIDE.fl, GE.world.t)?.from.iata} → ${GE.flightAt(RIDE.fl, GE.world.t)?.to.iata}`; + el.innerHTML = `
    +
    Ride${esc(nm)} · ${esc(tp)}${ic('people', 'sm')}2,318
    +
    +
    `; + el.classList.add('on'); + $('#rideX').addEventListener('click', () => RIDE.exit()); + $('#rideHeart').addEventListener('click', () => { for (let i = 0; i < 4; i++) setTimeout(RIDE.heart, i * 90); haptic(6); }); + $('#rideIn').addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.value.trim()) { RIDE.bubble('you', e.target.value.trim(), true); e.target.value = ''; } }); +}; +RIDE.bubble = function (who, text, mine) { + const p = Object.values(GE.PEOPLE).find((x) => x.h === who) || GE.PEOPLE.you; + const c = $('#chat'); if (!c) return; + const b = h('div', { class: 'bub' }); b.innerHTML = `${p.h[0].toUpperCase()}
    @${esc(p.h)}${who === 'rotorwash_ret' ? ' · former instructor' : ''}${esc(text)}
    `; + c.append(b); while (c.children.length > 6) c.firstChild.remove(); +}; +RIDE.nextChat = function () { const L = RIDE.hc ? CHAT_HELI : CHAT_JET; const [w, t] = L[RIDE.chatI++ % L.length]; RIDE.bubble(w, t); const n = $('#rideN'); if (n) n.textContent = fmt.n(2318 + Math.round(Math.sin(Date.now() / 4000) * 40 + RIDE.chatI * 7)); }; +RIDE.heart = function () { const r = $('#react'); if (!r || prefersReduced()) return; const s = h('span'); s.innerHTML = ic('heartf'); s.style.setProperty('--dx', (Math.random() * 40 - 20) + 'px'); s.style.left = (Math.random() * 16) + 'px'; s.style.color = ['#FF453A', '#FF9F0A', '#FFD166', '#7CC4FF'][Math.floor(Math.random() * 4)]; r.append(s); setTimeout(() => s.remove(), 2700); }; +RIDE.frame = function (view) { + if (!RIDE.on || !RIDE.s) return; const s = RIDE.s; const svg = $('#hudSvg'); if (!svg) return; + const w = svg.clientWidth, hh = svg.clientHeight; const cx = w / 2, cy = hh * 0.42; + const hdg = (((s.hdg + RIDE.yaw) * R2D) % 360 + 360) % 360; const roll = -s.roll * 0.6 * R2D; const pitchPx = (RIDE.pitch + (RIDE.hc ? -0.16 : -0.05)) * R2D * 6; + let g = ``; + g += ``; + for (const pd of [-10, -5, 5, 10]) { const y = cy + pitchPx - pd * 6; g += `${pd}`; } + g += ``; + g += ``; + // heading tape + const ty = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sat')) + 70; + g += ``; + for (let d = Math.floor(hdg / 5) * 5 - 40; d <= hdg + 40; d += 5) { const x = cx + (d - hdg) * 4; const dd = ((d % 360) + 360) % 360; g += ``; if (dd % 30 === 0) g += `${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? String(dd / 10).padStart(2, '0')}`; } + g += `${String(Math.round(hdg)).padStart(3, '0')}`; + svg.innerHTML = g; + $('#hSpd').innerHTML = `${Math.round(s.spd)}KT GS`; + $('#hAlt').innerHTML = `${fmt.n(Math.round(s.alt * 3281 / 10) * 10)}FT ${RIDE.hc ? 'AGL' : 'MSL'} · ${s.vs > 0 ? '+' : ''}${Math.round(s.vs / 50) * 50} FPM`; +}; +RIDE.exit = function (silent) { + if (!RIDE.on) return; RIDE.on = false; clearInterval(RIDE._ci); clearInterval(RIDE._ri); + $('#ride').classList.remove('on'); $('#ride').innerHTML = ''; + const v = Cam.view(400, 800); const [la, lo] = geo.ll(v.eye); + Cam.setRig(null); Cam.set({ lat: la, lon: lo, range: Math.max(0.002, v3.len(v.eye) - 1 + 0.002), tilt: 0.9, heading: RIDE.s ? RIDE.s.hdg : 0, shiftY: 0.06 }); + APP.setMode(''); + if (!silent) Cam.flyTo({ range: 0.012, tilt: 0.8 }); +}; diff --git a/mobile/vision/src/js/65-capture.js b/mobile/vision/src/js/65-capture.js new file mode 100644 index 000000000..bd989daf7 --- /dev/null +++ b/mobile/vision/src/js/65-capture.js @@ -0,0 +1,178 @@ +/* Capture: Look (AR — the God's-eye view projected around you on a rendered "camera" scene with + aircraft/satellite callouts, compass tape, horizon; drag to look) and Witness (shutter → sealed + bundle: real SHA-256 over the rendered frame, real ECDSA P-256 via WebCrypto, simulated IMU/GPS, + hash chain, gold seal; then drop it as a hologram here). */ +const CAP = (GE.capture = { mode: 'look', yaw: 0, pitch: 0.06, zoom: 1, on: false }); +GE.captures = []; +const SPOT = { lat: 41.8662, lon: -87.6072, alt: 0.042, name: 'Chicago lakefront', hdg: -52 * D2R }; +CAP.spot = SPOT; + +/* ---------- device key (non-extractable, IndexedDB when available) ---------- */ +const KEY = { pair: null, jwk: null, ready: null }; +function idb() { return new Promise((res, rej) => { try { const r = indexedDB.open('ge-vision', 1); r.onupgradeneeded = () => r.result.createObjectStore('keys'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); } catch (e) { rej(e); } }); } +KEY.get = function () { + if (KEY.ready) return KEY.ready; + KEY.ready = (async () => { + if (!hasSubtle()) return null; + try { const db = await idb(); const got = await new Promise((res) => { const tx = db.transaction('keys', 'readonly'); const rq = tx.objectStore('keys').get('device'); rq.onsuccess = () => res(rq.result); rq.onerror = () => res(null); }); if (got && got.privateKey) { KEY.pair = got; } } catch {} + if (!KEY.pair) { + try { + KEY.pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']); + try { const db = await idb(); await new Promise((res) => { const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').put(KEY.pair, 'device'); tx.oncomplete = res; tx.onerror = res; }); } catch {} + } catch (e) { console.warn('keygen', e); return null; } + } + try { KEY.jwk = await crypto.subtle.exportKey('jwk', KEY.pair.publicKey); } catch {} + return KEY.pair; + })(); + return KEY.ready; +}; +const hexToBytes = (hx) => new Uint8Array(hx.match(/../g).map((b) => parseInt(b, 16))); +async function signHex(hashHex) { const k = await KEY.get(); if (!k) return null; const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, k.privateKey, hexToBytes(hashHex)); return fmt.hex(sig); } +async function verifyHex(hashHex, sigHex) { const k = await KEY.get(); if (!k || !sigHex) return false; try { return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, k.publicKey, hexToBytes(sigHex), hexToBytes(hashHex)); } catch { return false; } } +GE.crypto = { KEY, signHex, verifyHex }; + +/* ---------- simulated golden hour at the spot ---------- */ +CAP.goldenMs = function () { + const base = Date.now(); let best = base, bd = 1e9; + for (let m = -24 * 60; m <= 24 * 60; m += 4) { const ms = base + m * 60000; const e = astro.elev(ms, SPOT.lat, SPOT.lon); const e2 = astro.elev(ms + 60000, SPOT.lat, SPOT.lon); if (e2 < e) { const d = Math.abs(e - 3.2); if (d < bd) { bd = d; best = ms; } } } + return best; +}; + +/* ---------- enter / exit ---------- */ +CAP.enter = function () { + CAP.on = true; CAP.ms = CAP.goldenMs(); CAP.t = (CAP.ms - NOW) / 1000; CAP.sun = astro.sun(CAP.ms); + // face the sunset over the skyline + const sunAz = geo.bearing(SPOT.lat, SPOT.lon, CAP.sun.lat, CAP.sun.lon); + CAP.yaw = 0; SPOT.hdg = sunAz * 0.35 + (-52 * D2R) * 0.65; + GE.renderOverride = { t: CAP.t, sun: CAP.sun, simMs: CAP.ms, offsetH: (CAP.ms - Date.now()) / 3600000, exposure: 1.25, cloudK: 0.5 }; + Cam.setRig({ blendTime: 0.01, pose: () => Cam.pose(SPOT.lat, SPOT.lon, SPOT.alt, SPOT.hdg + CAP.yaw, CAP.pitch, 0, 62 * D2R / CAP.zoom), look(dx, dy) { CAP.yaw -= dx * 0.0042 / CAP.zoom; CAP.pitch = clamp(CAP.pitch - dy * 0.0035 / CAP.zoom, -0.5, 1.2); }, zoom(r) { CAP.zoom = clamp(CAP.zoom / r, 0.6, 4); CAP.syncZoom(); } }); + Cam.rigBlend = 1; + GE.scenes.ensure(GE.scenes.byId.chi); + CAP.render(); + KEY.get(); +}; +CAP.exit = function () { if (!CAP.on) return; CAP.on = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); }; +CAP.render = function () { + const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1]; + el.innerHTML = `
    + +
    +
    ${ic('location', 'xs')}Simulated camera · ${SPOT.name}${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}
    +
    +
    +
    ${[0.5, 1, 3].map((z) => ``).join('')}
    +
    +
    + +
    +
    `; + el.querySelectorAll('[data-z]').forEach((b) => b.addEventListener('click', () => { CAP.zoom = +b.dataset.z; CAP.syncZoom(); haptic(4); })); + el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.mode = b.dataset.m; CAP.render(); haptic(6); })); + $('#shutter').addEventListener('click', () => { if (CAP.mode === 'look') { CAP.mode = 'witness'; CAP.render(); return; } CAP.shoot(); }); + $('#lastcap').addEventListener('click', () => { if (GE.captures.length) GE.you.inspect(GE.captures.length - 1); }); + $('#capFlip').addEventListener('click', () => APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`)); +}; +CAP.syncZoom = () => $$('#v-capture [data-z]').forEach((x) => x.classList.toggle('on', +x.dataset.z === CAP.zoom)); + +/* ---------- per-frame AR overlay ---------- */ +CAP.frame = function (view) { + if (!CAP.on) return; + const W = GE.world; const t = CAP.t + (performance.now() / 1000 - (CAP._t0 || (CAP._t0 = performance.now() / 1000))); + const host = $('#callouts'); if (!host) return; + // compass tape + const svg = $('#ctape'); const w = svg.clientWidth; const hdg = (((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360; + const pxPerDeg = w / (view.fovY * R2D * view.aspect); + let g = ``; + for (let d = Math.floor((hdg - 60) / 5) * 5; d <= hdg + 60; d += 5) { const x = w / 2 + (d - hdg) * pxPerDeg; if (x < -10 || x > w + 10) continue; const dd = ((d % 360) + 360) % 360; g += ``; if (dd % 15 === 0) g += `${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? dd}`; } + g += `${Math.round(hdg)}° · pitch ${Math.round(CAP.pitch * R2D)}°`; + svg.innerHTML = g; + // contacts + const want = []; + const sun = CAP.sun; + for (let i = 0; i < GE.FLIGHTS.length; i++) { + const f = GE.FLIGHTS[i]; const p = GE.flightAt(f, CAP.t); if (!p) continue; + const dkm = geo.dist(SPOT.lat, SPOT.lon, p.lat, p.lon) * EARTH_KM; if (dkm > 110) continue; + const v = v3.scale(p.v, 1 + p.altKm / EARTH_KM); want.push({ k: 'f' + i, v, c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)} ft · ${dkm.toFixed(0)} km`, d: dkm }); + } + const obs = geo.vec(SPOT.lat, SPOT.lon); const up = v3.norm(obs); + if (!CAP._sats || Math.abs(CAP._satT - CAP.t) > 20) { + CAP._sats = []; CAP._satT = CAP.t; + for (let i = 0; i < GE.SATS.length; i += 3) { const s = GE.SATS[i]; if (s.kind > 1 && s.kind !== 6 && s.kind !== 5) continue; const p = GE.satAt(s, CAP.t, sun.gmst); const d = v3.sub(p, obs); const el = Math.asin(v3.dot(v3.norm(d), up)) * R2D; if (el > 12) CAP._sats.push({ s, el, km: v3.len(d) * EARTH_KM }); } + CAP._sats.sort((a, b) => a.km - b.km); CAP._sats = CAP._sats.slice(0, 3); + } + for (const o of CAP._sats) { const p = GE.satAt(o.s, CAP.t, sun.gmst); want.push({ k: 's' + o.s.name, v: p, c: 'var(--orbit)', i: 'satellite', t: o.s.name, s: `${Math.round(o.s.alt)} km up · ${Math.round(o.el)}° elev`, d: o.km }); } + const seen = new Set(); let n = 0; + for (const it of want.sort((a, b) => a.d - b.d)) { + const s = W.project(view, it.v); let el = host.querySelector(`[data-k="${it.k}"]`); + const onScreen = s && s.x > 8 && s.x < view.w - 8 && s.y > 60 && s.y < view.h - 230; + if (!onScreen || n >= 7) { if (el) el.remove(); continue; } + n++; seen.add(it.k); + if (!el) { el = h('div', { 'data-k': it.k }); el.innerHTML = `
    ${ic(it.i, 'sm')}${esc(it.t)}
    `; host.append(el); } + el.querySelector('[data-s]').textContent = it.s; + el.querySelector('.reticle').style.transform = `translate(${s.x}px, ${s.y}px)`; + el.querySelector('.callout').style.transform = `translate(${s.x + 24}px, ${s.y}px) translate(0, -50%)`; + } + host.querySelectorAll('[data-k]').forEach((e) => { if (!seen.has(e.dataset.k)) e.remove(); }); + // witness sensors + if (CAP.mode === 'witness') { + const ss = $('#sensors'); const tt = performance.now() / 1000; + const imu = Array.from({ length: 40 }, (_, i) => Math.sin((tt * 3 + i * 0.4)) * 0.4 + Math.sin(tt * 7.3 + i) * 0.15); + ss.innerHTML = `
    IMU 50 Hz${(imu[39] * 0.12).toFixed(3)} g
    ${GE.sparkPath(imu, 140, 26, '#5CF2A0', 1.3)}
    GPS±4 m · ${SPOT.lat.toFixed(5)}, ${SPOT.lon.toFixed(5)}
    Heading${Math.round(hdg)}° true
    KeyP-256 · ${KEY.pair ? 'device-bound' : '…'}
    `; + } +}; + +/* ---------- shoot & seal ---------- */ +CAP.shoot = async function () { + if (CAP.busy) return; CAP.busy = true; haptic([5, 20, 30]); + const flash = h('div', { style: { position: 'absolute', inset: 0, background: '#fff', zIndex: 72, opacity: 1, transition: 'opacity .45s ease' } }); $('#app').append(flash); requestAnimationFrame(() => (flash.style.opacity = 0)); setTimeout(() => flash.remove(), 500); + const start = Date.now(); + // render the frame offscreen at 1200×900 + const W0 = 1200, H0 = 900; const view = Cam.view(W0, H0); + const cv = GE.thumbs.renderView(view, W0, H0, { ...GE.renderOverride, px: 1.6 }); + const blob = await new Promise((res) => cv.toBlob(res, 'image/jpeg', 0.9)); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const thumb = (() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; c.getContext('2d').drawImage(cv, 0, 0, 320, 240); return c.toDataURL('image/jpeg', 0.8); })(); + const hdgDeg = Math.round((((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360); + const imu = Array.from({ length: 150 }, (_, i) => ({ t: i * 20, a: +(hdgDeg + Math.sin(i / 9) * 0.6).toFixed(2), b: +(CAP.pitch * R2D + Math.sin(i / 7) * 0.3).toFixed(2), g: +(Math.sin(i / 5) * 0.4).toFixed(2), ax: +(Math.sin(i / 3) * 0.02).toFixed(3), ay: +(9.806 + Math.sin(i / 4) * 0.03).toFixed(3), az: +(Math.cos(i / 3) * 0.02).toFixed(3) })); + const session = { start, end: start + 3000, imu, gps: [0, 1, 2].map((k) => ({ t: start + k * 1000, lat: +(SPOT.lat + k * 1e-6).toFixed(6), lon: +SPOT.lon.toFixed(6), acc: 4, alt: 182 })), heading: hdgDeg }; + const context = { device: 'Simulated camera (concept)', ua: navigator.userAgent.slice(0, 64), screen: `${screen.width}x${screen.height}@${Math.round((devicePixelRatio || 1) * 100) / 100}`, timezone: GE.ME.tz, networkTimeOffsetMs: 12 }; + const prev = GE.captures.length ? GE.captures[GE.captures.length - 1].hash : '0'.repeat(64); + const b = { bytes, thumb, session, context, prev, place: SPOT.name, lat: SPOT.lat, lon: SPOT.lon, at: start }; + CAP.ceremony(b, cv); + b.mediaHash = await sha256(bytes); + b.hash = await sha256(canon({ media: b.mediaHash, session, context, prev })); + b.sig = await signHex(b.hash); + b.jwk = KEY.jwk; + GE.captures.push(b); + CAP.ceremonyFill(b); + CAP.busy = false; +}; +CAP.ceremony = function (b, cv) { + const ov = $('#sealOv'); + ov.innerHTML = `
    +
    +
    ${ic('check', 'xs')}
    Frame hashed · SHA-256 of ${fmt.n(b.bytes.length)} JPEG bytes
    …
    +
    ${ic('check', 'xs')}
    Session sealed · IMU 150 samples @ 50 Hz · GPS ±4 m · heading ${b.session.heading}°${GE.sparkPath(b.session.imu.map((x) => x.g + x.b * 0.02), 300, 30, '#5CF2A0', 1.2)}
    +
    ${ic('check', 'xs')}
    Chained to this device’s previous bundle
    prev ${b.prev.slice(0, 24)}…
    +
    ${ic('check', 'xs')}
    Bundle hash
    …
    +
    ${ic('check', 'xs')}
    Signed · ECDSA P-256, key generated on device, non-extractable
    …
    +
    +
    +
    +

    None of this proves truth. It raises the cost of lying.

    `; + const ph = $('#bph'); const c2 = document.createElement('canvas'); c2.width = 600; c2.height = 450; c2.getContext('2d').drawImage(cv, 0, 0, 600, 450); ph.prepend(c2); + ov.classList.add('on'); +}; +CAP.ceremonyFill = async function (b) { + const step = (i) => new Promise((r) => setTimeout(() => { const s = $(`.step[data-s="${i}"]`); s && s.classList.add('on', 'done'); haptic(4); r(); }, prefersReduced() ? 30 : 380)); + const stream = async (id, hex) => { const el = $('#' + id); if (!el) return; if (prefersReduced()) { el.textContent = hex; return; } for (let i = 0; i <= hex.length; i += 4) { el.textContent = hex.slice(0, i) + (i < hex.length ? '▍' : ''); await new Promise((r) => setTimeout(r, 8)); } }; + $(`.step[data-s="0"]`).classList.add('on'); await stream('sh0', b.mediaHash); await step(0); + await step(1); await step(2); + $(`.step[data-s="3"]`).classList.add('on'); await stream('sh3', b.hash); await step(3); + $(`.step[data-s="4"]`).classList.add('on'); await stream('sh4', b.sig ? b.sig : 'WebCrypto unavailable here — bundle hashed but unsigned'); await step(4); + $('#bigseal').classList.add('on'); haptic([10, 40, 20]); + for (const id of ['bbtns', 'bbtns2']) { const e = $('#' + id); e.style.opacity = 1; e.style.pointerEvents = 'auto'; } + $('#bdrop').addEventListener('click', () => { GE.world.userHolos.push({ lat: b.lat, lon: b.lon, title: 'Your capture · ' + b.place, by: 'you', at: b.at }); b.dropped = true; $('#sealOv').classList.remove('on'); APP.toast(`${ic('cube', 'sm')} Hologram dropped · visible to people here`); CAP.render(); GE.you.render(); }); + $('#bver').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.you.inspect(GE.captures.indexOf(b)); }); + $('#bdone').addEventListener('click', () => { $('#sealOv').classList.remove('on'); CAP.render(); GE.you.render(); }); +}; diff --git a/mobile/vision/src/js/66-threads.js b/mobile/vision/src/js/66-threads.js new file mode 100644 index 000000000..b234bafc6 --- /dev/null +++ b/mobile/vision/src/js/66-threads.js @@ -0,0 +1,288 @@ +/* Threads: reconstructions people build together. Three strata — Observations (immutable, hashed, + sourced), Hypotheses (person/AI, confidence, citations, agree/dispute), Narrative (camera path through + space-time synced to the ribbon) — plus a real SHA-256 hash-chain ledger you can verify and tamper + with, and an AI refine composer whose output is recorded as a *proposed* hypothesis. */ +const THR = (GE.threads = { ws: null }); +const P = GE.PEOPLE; +const KT = GE.scenes.FLOOD.startMs; const HR_ = 3600000, MIN = 60000; +const KO = GE.scenes.KOSHI; +const THREADS = { + koshi: { + id: 'koshi', sit: 'koshi', title: 'Bhote Koshi outburst flood', place: 'Rasuwagadhi → Syaphrubesi, Nepal', date: '26 Aug 2026', + window: { start: KT - 1.5 * HR_, end: KT + 6 * HR_ }, people: ['hydro', 'kora', 'tamang', 'geo', 'sky', 'ais'], + cam: { lat: 28.205, lon: 85.352, range: 0.0042, heading: 0.32, tilt: 1.02 }, + obs: [ + { id: 'O1', i: 'frame', f: 'infra', t: 'Optical satellite tile — before', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 24, 4, 52), meta: ['10 m', 'cloud 6%'] }, + { id: 'O2', i: 'frame', f: 'infra', t: 'Optical satellite tile — after: new scour along 9.4 km of channel', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 27, 4, 58), meta: ['10 m', 'cloud 12%'] }, + { id: 'O3', i: 'drop', f: 'haz', t: 'River gauge, Syaphrubesi: stage 3.1 → 7.8 m', src: 'Gauge feed (illustrative values)', at: KT + 1.75 * HR_, meta: ['15-min series', 'peak 09:30 UTC'] }, + { id: 'O4', i: 'wave', f: 'haz', t: 'No M ≥ 3 earthquake within 50 km in the prior 24 h', src: 'Seismic catalog', at: KT, meta: ['rules out seismic trigger'] }, + { id: 'O5', i: 'seal', f: 'eyes', t: 'Signed capture — water reaches the road at Timure', src: '@tamang_lens · illustrative', at: KT + 27 * MIN, meta: ['IMU ✓', 'GPS ±6 m', 'heading 214°'], lat: 28.2551, lon: 85.3712 }, + { id: 'O6', i: 'seal', f: 'eyes', t: 'Signed capture — debris against the Syaphrubesi bridge deck', src: '@hydro_nerd · illustrative', at: KT + 61 * MIN, meta: ['IMU ✓', 'GPS ±4 m', 'heading 352°'], lat: 28.1629, lon: 85.3402 }, + { id: 'O7', i: 'seal', f: 'eyes', t: 'Signed capture — fresh scour line at Rasuwagadhi', src: '@geodesy_dan · illustrative', at: KT + 21.5 * HR_, meta: ['IMU ✓', 'GPS ±5 m'], lat: 28.2802, lon: 85.3797 }, + { id: 'O8', i: 'helicopter', f: 'sky', t: 'ADS-B: rescue helicopter, 3 sorties along the valley', src: 'ADS-B/MLAT (illustrative track)', at: KT + 2.3 * HR_, meta: ['10:05–13:40 UTC'] }, + { id: 'O9', i: 'cloud', f: 'wx', t: 'Upstream rain gauge: 4 mm in 24 h', src: 'Met station (illustrative)', at: KT - 1 * HR_, meta: ['not a rain-driven flood'] }, + { id: 'O10', i: 'frame', f: 'infra', t: 'Glacial lake area change upstream: −0.42 km²', src: 'Derived from O1/O2-class imagery (illustrative)', at: Date.UTC(2026, 7, 27, 5, 0), meta: ['outside border, upstream'] }, + ], + hyps: [ + { id: 'H1', by: 'hydro', text: 'A glacial-lake outburst upstream — not rainfall — triggered the flood.', conf: 0.82, ci: [0.71, 0.9], cites: ['O4', 'O9', 'O10'], agree: 31, dispute: 2, status: 'supported' }, + { id: 'H2', by: 'kora', text: 'The crest passed Timure about 08:12 UTC and reached Syaphrubesi ~35 min later (≈ 5 m/s).', conf: 0.68, ci: [0.55, 0.79], cites: ['O3', 'O5', 'O6'], agree: 18, dispute: 4, status: 'supported' }, + { id: 'H3', by: 'tamang', text: 'Clip O5 was filmed from the north bank, about 140 m upstream of the Timure bridge.', conf: 0.74, ci: [0.6, 0.84], cites: ['O5'], agree: 12, dispute: 1, status: 'supported' }, + { id: 'H4', by: 'ai', ai: true, text: 'Peak stage at Syaphrubesi lagged the visible crest by ~20 min — consistent with debris damming at the bridge.', conf: 0.41, ci: [0.22, 0.6], cites: ['O3', 'O6'], agree: 3, dispute: 5, status: 'disputed', prompt: 'Why does the gauge peak late?' }, + ], + beats: [ + { at: KT, t: '07:45', text: 'Outburst upstream (inferred). The valley is quiet; the river is at its normal 3 m stage.', cites: ['H1', 'O4', 'O9'], cam: { lat: 28.262, lon: 85.372, range: 0.0032, heading: 3.4, tilt: 1.05 } }, + { at: KT + 20 * MIN, t: '08:05', text: 'The crest reaches Rasuwagadhi and the border bridge.', cites: ['H2', 'O7'], cam: { lat: 28.272, lon: 85.37, range: 0.0018, heading: 0.6, tilt: 1.12 } }, + { at: KT + 27 * MIN, t: '08:12', text: 'A witness on the north bank at Timure films water reaching the road.', cites: ['H3', 'O5'], cam: { lat: 28.247, lon: 85.364, range: 0.0016, heading: 0.35, tilt: 1.15 } }, + { at: KT + 61 * MIN, t: '08:46', text: 'Debris piles against the Syaphrubesi bridge deck.', cites: ['O6', 'H4'], cam: { lat: 28.152, lon: 85.336, range: 0.0016, heading: 0.2, tilt: 1.12 } }, + { at: KT + 105 * MIN, t: '09:30', text: 'Gauge peaks at 7.8 m — about 20 minutes after the visible crest.', cites: ['O3', 'H4'], cam: { lat: 28.15, lon: 85.33, range: 0.0026, heading: 0.9, tilt: 1.05 } }, + { at: KT + 140 * MIN, t: '10:05', text: 'First rescue sortie climbs the valley.', cites: ['O8'], cam: { lat: 28.19, lon: 85.345, range: 0.0045, heading: 0.3, tilt: 1.0 } }, + ], + suggestions: ['Align the witness clip to the corridor using the bridge as a landmark', 'Estimate crest speed between Timure and Syaphrubesi', 'What would falsify H1?'], + fallback: 'Using the Syaphrubesi bridge as a fixed landmark, clip [O6] was taken from the east abutment looking upstream; matching its horizon to the IMU pitch places the crest front 85 ± 20 m upstream of the bridge at 08:46 UTC. That timing agrees with the gauge [O3] if the crest travelled 4.6 ± 0.6 m/s from Timure [O5].\nConfidence: 0.63', + }, + concert: { + id: 'concert', sit: 'concert', title: 'Stadium encore in 4D', place: 'Wembley, London', date: 'last night', + window: { start: Date.now() - 21 * HR_, end: Date.now() - 19.5 * HR_ }, people: ['crowd', 'sky', 'kora', 'geo'], + cam: { lat: 51.5546, lon: -0.2822, range: 0.00042, heading: 0.6, tilt: 1.1 }, + obs: [ + { id: 'O1', i: 'seal', f: 'eyes', t: '2,311 signed phone captures from 86 seat blocks', src: 'Signed bundles (illustrative)', at: Date.now() - 20 * HR_, meta: ['IMU ✓ on 2,204', 'GPS indoor ±12 m'] }, + { id: 'O2', i: 'wave', f: 'eyes', t: 'Audio fingerprints align 1,980 clips to ±9 ms', src: 'Derived', at: Date.now() - 19.8 * HR_, meta: ['common track'] }, + { id: 'O3', i: 'video', f: 'eyes', t: 'Venue public camera, concourse north', src: 'Public feed (illustrative)', at: Date.now() - 20 * HR_, meta: ['720p'] }, + { id: 'O4', i: 'ticket', f: 'ground', t: 'Seat map & stage geometry', src: 'Published venue plan', at: Date.now() - 30 * 24 * HR_, meta: ['static'] }, + ], + hyps: [ + { id: 'H1', by: 'crowd', text: 'The encore began at 22:41:07 local, ±0.2 s.', conf: 0.9, ci: [0.84, 0.95], cites: ['O1', 'O2'], agree: 210, dispute: 3, status: 'supported' }, + { id: 'H2', by: 'geo', text: 'The pyro cue at 22:44 is visible from 86 of 92 seat blocks; 6 blocks are occluded by the roof cantilever.', conf: 0.71, ci: [0.6, 0.8], cites: ['O1', 'O4'], agree: 44, dispute: 6, status: 'supported' }, + ], + beats: [ + { at: Date.now() - 20 * HR_ - 10 * MIN, t: '22:41', text: 'Lights down. 2,311 phones come up together.', cites: ['H1'], cam: { lat: 51.5552, lon: -0.2815, range: 0.00035, heading: 0.9, tilt: 1.15 } }, + { at: Date.now() - 20 * HR_, t: '22:44', text: 'Pyro cue — seen from 86 blocks at once.', cites: ['H2'], cam: { lat: 51.5568, lon: -0.2778, range: 0.0003, heading: 3.6, tilt: 1.12 } }, + { at: Date.now() - 20 * HR_ + 12 * MIN, t: '22:56', text: 'Final chorus; the crowd reconstruction is densest here.', cites: ['O1', 'O2'], cam: { lat: 51.556, lon: -0.2796, range: 0.0006, heading: 2.2, tilt: 0.9 } }, + ], + suggestions: ['Which seat blocks saw the pyro cue?', 'Align the concourse camera to the crowd timeline'], + fallback: 'The concourse camera [O3] shows the crowd surge 2.1 s after the audio onset in [O2]; offsetting its clock by −2.1 s aligns it with 1,980 crowd clips [O1].\nConfidence: 0.58', + }, + climb: { + id: 'climb', sit: 'climb', title: 'Free-climber on a Midtown tower', place: 'Midtown Manhattan (fictional tower)', date: 'this morning', + window: { start: Date.now() - 11 * HR_, end: Date.now() - 7 * HR_ }, people: ['news', 'sky', 'kora', 'geo', 'ais'], + cam: { lat: 40.7533, lon: -73.9866, range: 0.00034, heading: 0.62, tilt: 1.2 }, + obs: [ + { id: 'O1', i: 'helicopter', f: 'sky', t: 'ADS-B tracks: 3 news helicopters orbiting the tower', src: 'ADS-B (illustrative callsigns)', at: Date.now() - 9 * HR_, meta: ['1 Hz', '2 h 14 m'] }, + { id: 'O2', i: 'video', f: 'eyes', t: 'Broadcast frames, time-coded', src: 'Public broadcast (illustrative)', at: Date.now() - 9 * HR_, meta: ['29.97 fps'] }, + { id: 'O3', i: 'seal', f: 'eyes', t: '41 signed street-level clips', src: 'Signed bundles (illustrative)', at: Date.now() - 9 * HR_, meta: ['IMU ✓', 'GPS ±5 m'] }, + { id: 'O4', i: 'cube', f: 'infra', t: 'Tower model, 412 m (fictional building)', src: 'Model', at: Date.now() - 400 * 24 * HR_, meta: ['static'] }, + ], + hyps: [ + { id: 'H1', by: 'news', text: 'The climb started at 06:52 local on the northeast face.', conf: 0.77, ci: [0.66, 0.86], cites: ['O2', 'O3'], agree: 58, dispute: 4, status: 'supported' }, + { id: 'H2', by: 'geo', text: 'Climber height over time is recoverable to ±3 m by triangulating helicopter camera positions (from ADS-B) with frame bearings.', conf: 0.69, ci: [0.55, 0.8], cites: ['O1', 'O2', 'O4'], agree: 33, dispute: 7, status: 'supported' }, + ], + beats: [ + { at: Date.now() - 10.5 * HR_, t: '06:52', text: 'First witness clip from the street: the climber is at the 3rd setback.', cites: ['H1', 'O3'], cam: { lat: 40.7538, lon: -73.9852, range: 0.00022, heading: 0.5, tilt: 1.3 } }, + { at: Date.now() - 9.5 * HR_, t: '07:55', text: 'Three news helicopters in orbit; their ADS-B tracks pin each broadcast camera.', cites: ['O1', 'H2'], cam: { lat: 40.751, lon: -73.99, range: 0.0012, heading: 0.7, tilt: 1.0 } }, + { at: Date.now() - 8.6 * HR_, t: '09:06', text: 'The climber reaches the crown at 384 m.', cites: ['H2'], cam: { lat: 40.7549, lon: -73.9848, range: 0.0003, heading: 0.4, tilt: 1.42 } }, + ], + suggestions: ['Triangulate the climber at 08:30 from two helicopters', 'Which face was the route on?'], + fallback: 'At 08:30 the bearings from N9 NEWS and SKY 4 [O1] to the climber in broadcast frames [O2] intersect 232 ± 4 m up the northeast face of the tower model [O4].\nConfidence: 0.66', + }, +}; +THR.data = THREADS; + +/* ---------- 3D extras per reconstruction (GPU sets, built lazily) ---------- */ +const EXTRAS = {}; +GE.extrasFor = function (id) { + if (EXTRAS[id] !== undefined) return EXTRAS[id]; + const T = THREADS[id] || (GE.SIT.byId[id]?.thread && THREADS[GE.SIT.byId[id].thread]); + if (!T) return (EXTRAS[id] = null); + const sp = [], ln = []; + const gold = [1, 0.84, 0.42, 1]; + if (T.id === 'koshi') { + GE.scenes.ensure(GE.scenes.byId.koshi); + const gz = (la, lo) => GE.scenes.groundAt(la, lo) / 1000; + for (const o of T.obs) if (o.lat) { const z = gz(o.lat, o.lon); pushSpr(sp, geo.vec(o.lat, o.lon, z + 0.09), gold, 26, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [geo.vec(o.lat, o.lon, z), geo.vec(o.lat, o.lon, z + 0.09)], [1, 0.85, 0.5, 0.9]); + // capture frustum toward the river + const brg = { O5: 214, O6: 352, O7: 160 }[o.id] * D2R || 0; const tip = geo.vec(o.lat, o.lon, z + 0.012); + for (const da of [-0.35, 0.35]) for (const dz of [-0.04, 0.04]) { const [la2, lo2] = geo.dest(o.lat, o.lon, brg + da, 0.45); pushPolyline(ln, [tip, geo.vec(la2, lo2, gz(la2, lo2) + 0.012 + dz)], [1, 0.82, 0.4, 0.55]); } + } + // gauge & villages + pushSpr(sp, geo.vec(28.158, 85.336, gz(28.158, 85.336) + 0.06), [0.4, 0.75, 1, 1], 20, 9, -10, -1e9, 1e9, 0, 0.3, 0.4); + // rescue helicopter track (illustrative) up the valley + const trk = KO.map(([la, lo], i) => geo.vec(la, lo, gz(la, lo) + 0.35 + (i % 3) * 0.05)); pushPolyline(ln, trk, [0.62, 1, 0.6, 0.7], 1); + } else if (T.id === 'concert') { + const c = [51.556, -0.2796]; const stage = geo.vec(...geo.offset(c[0], c[1], -40, 0), 0.015); + const r = rng(9); + for (let i = 0; i < 160; i++) { const a = r() * TAU; const rx = 70 + r() * 55, ry = 52 + r() * 50; const [la, lo] = geo.offset(c[0], c[1], Math.cos(a) * rx, Math.sin(a) * ry); const z = 0.008 + r() * 0.03; const p = geo.vec(la, lo, z); pushSpr(sp, p, [1, 0.86, 0.45, 1], 7, 0, -10, -1e9, 1e9, 0, 0, 0.6); if (i % 2 === 0) pushPolyline(ln, [p, v3.lerp(p, stage, 0.35 + r() * 0.2)], [1, 0.8, 0.4, 0.28]); } + pushSpr(sp, stage, [1, 0.5, 1, 1], 30, 9, -10, -1e9, 1e9, 0, 0.1, 0.9); + } else if (T.id === 'climb') { + const tw = [40.7553, -73.9838]; + for (const hc of GE.HELI_CIRCUITS.filter((x) => x.orbit && x.lat === 40.7553)) { const pts = []; for (let k = 0; k <= 64; k++) { const [la, lo] = geo.dest(hc.lat, hc.lon, (k / 64) * TAU, hc.r); pts.push(geo.vec(la, lo, hc.alt)); } pushPolyline(ln, pts, [0.62, 1, 0.6, 0.55], 1); } + const r = rng(5); + for (let i = 0; i < 41; i++) { const a = r() * TAU, d = 0.12 + r() * 0.35; const [la, lo] = geo.dest(tw[0], tw[1], a, d); const p = geo.vec(la, lo, 0.002); pushSpr(sp, p, [1, 0.84, 0.42, 1], 11, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [p, geo.vec(tw[0], tw[1], 0.12 + r() * 0.25)], [1, 0.8, 0.4, 0.22]); } + } + const set = { sprites: sp.length ? [makeSpriteSet('x' + id, sp)] : null, lines: ln.length ? [makeLineSet(ln)] : null }; + return (EXTRAS[id] = set); +}; +GE.setExtras = function (id) { GE.world.activeExtras = id ? GE.extrasFor(id) : null; GE.world.activeExtrasId = id; }; + +/* ---------- ledger (real SHA-256 hash chain) ---------- */ +THR.ledgers = {}; +async function buildLedger(T) { + const entries = []; let ts = T.window.start - 6 * HR_; + const push = (type, actor, payload) => { entries.push({ i: entries.length, ts: (ts += 7 * MIN + entries.length * 3 * MIN), type, actor, payload }); }; + push('thread.create', 'kora_maps', { title: T.title, place: T.place }); + for (const o of T.obs) push('observation.add', o.src.startsWith('@') ? o.src.split(' ')[0].slice(1) : 'ingest', { id: o.id, title: o.t, source: o.src, at: o.at, contentHash: o.hash }); + for (const hy of T.hyps) { push(hy.ai ? 'ai.proposal' : 'hypothesis.propose', hy.ai ? 'claude' : P[hy.by].h, hy.ai ? { id: hy.id, text: hy.text, cites: hy.cites, prompt: hy.prompt, model: 'claude (window.claude sample)', diff: '+' + hy.id } : { id: hy.id, text: hy.text, cites: hy.cites, confidence: hy.conf }); push('vote', P[T.people[(entries.length) % T.people.length]].h, { target: hy.id, agree: true }); } + let prev = '0'.repeat(64); + for (const e of entries) { e.digest = await sha256(canon(e.payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); prev = e.hash; } + THR.ledgers[T.id] = entries; return entries; +} +THR.append = async function (T, type, actor, payload) { + const L = THR.ledgers[T.id] || (await buildLedger(T)); const prev = L[L.length - 1].hash; + const e = { i: L.length, ts: Date.now(), type, actor, payload }; e.digest = await sha256(canon(payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); L.push(e); return e; +}; +THR.verify = async function (L, onStep) { + let prev = '0'.repeat(64), ok = true; + for (const e of L) { const dg = await sha256(canon(e.payload)); const hh = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: dg, prev })); const good = ok && dg === e.digest && e.prev === prev && hh === e.hash; if (!good) ok = false; onStep && (await onStep(e, good)); prev = e.hash; } + return ok; +}; +THR.init = async function () { + for (const T of Object.values(THREADS)) { for (const o of T.obs) o.hash = await sha256(canon({ id: o.id, t: o.t, src: o.src, at: o.at, meta: o.meta })); await buildLedger(T); } +}; + +/* ---------- list ---------- */ +THR.render = function () { + const v = $('#v-threads'); + v.innerHTML = `
    ${ic('seal', 'xs')} Signed evidence · open ledgers

    Threads

    +
    ${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `${P[k].h[0].toUpperCase()}`).join('')}37 people reconstructing now
    + ${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `

    ${esc(T.title)}

    ${esc(T.place)} · ${esc(T.date)}

    4D
    ${T.obs.length}observations
    ${T.hyps.length}hypotheses
    ${THR.ledgers[T.id]?.length || '–'}ledger
    ${T.people.slice(0, 4).map((k) => `${P[k].h[0].toUpperCase()}`).join('')}
    `; }).join('')} +

    Concept · illustrative data. Witness posts, handles and claims are fictional.

    +
    `; + $('#thMe').addEventListener('click', () => APP.setTab('you')); + $('#newThread').addEventListener('click', () => APP.toast(`${ic('threads', 'sm')} New thread · add your first observation`)); + $$('.tcard').forEach((c) => { + c.addEventListener('click', () => THR.openWs(c.dataset.t)); + const cv = c.querySelector('canvas'); const s = GE.SIT.byId[c.dataset.id]; const done = GE.thumbs.done.get(s.id); + if (done) cv.getContext('2d').drawImage(done, 0, 0, cv.width, cv.height); else { GE.thumbs.fallback(s, cv); } + }); +}; + +/* ---------- workspace ---------- */ +THR.openWs = function (id) { + const T = THREADS[id]; if (!T) return; const W = GE.world; + if (APP.tab !== 'threads') APP.setTab('threads'); + THR.ws = { T, seg: THR.ws?.T === T ? THR.ws.seg : 'obs', beats: T.beats, playing: false }; + $('#tlist').style.display = 'none'; + const ws = $('#ws'); ws.classList.add('on'); + W.setWindow(T.window); W.setTime(T.beats[Math.min(2, T.beats.length - 1)].at); + GE.setExtras(T.id); + for (const lid of ['signed', 'holo', 'helis']) W.layerById[lid].on = true; + const wide = APP.isWide(); + Cam.autoSpin = 0; Cam.flyTo({ ...T.cam, shiftY: wide ? 0 : 0.6, shiftX: 0 }); + APP.setMode('workspace'); + THR.renderWs(); +}; +THR.closeWs = function (silent) { + if (!THR.ws) return; THR.stop(); THR.ws = null; const W = GE.world; + $('#ws').classList.remove('on'); $('#ws').innerHTML = ''; const tl = $('#tlist'); if (tl) tl.style.display = ''; + W.setWindow(null); W.setTime(null); GE.setExtras(null); APP.setMode(''); + if (!silent) Cam.flyTo(APP.heroCam()); +}; +THR.renderWs = function () { + const { T, seg } = THR.ws; const ws = $('#ws'); + ws.innerHTML = `
    ${esc(T.title)}${esc(T.place)} · ${esc(T.date)}${T.people.slice(0, 4).map((k, i) => `${P[k].h[0].toUpperCase()}`).join('')}
    +
    ${[['obs', 'Observations'], ['hyp', 'Hypotheses'], ['nar', 'Narrative'], ['led', 'Ledger']].map(([k, l]) => ``).join('')}
    + ${seg === 'hyp' ? `
    ${T.suggestions.map((s) => ``).join('')}
    ` : ''}
    `; + $('#wsBack').addEventListener('click', () => THR.closeWs()); + $$('#wsSeg button').forEach((b) => b.addEventListener('click', () => { THR.ws.seg = b.dataset.s; THR.renderWs(); haptic(4); })); + const c = $('#wsc'); + if (seg === 'obs') c.innerHTML = `
    ${ic('lock', 'xs')} Stratum 1 · Observations — immutable, hashed, sourced
    ` + T.obs.map((o) => `
    ${ic(o.i)}
    ${o.id} ${esc(o.t)}
    ${esc(o.src)}${fmt.utc(o.at)}${o.meta.map((m) => `${esc(m)}`).join('')}sha256 ${o.hash ? o.hash.slice(0, 12) + '…' : '…'}
    `).join(''); + if (seg === 'hyp') { c.innerHTML = `
    ${ic('people', 'xs')} Stratum 2 · Hypotheses — cite, agree, dispute, supersede
    `; THR.renderHyps(); THR.bindComposer(); } + if (seg === 'nar') THR.renderNarr(c); + if (seg === 'led') THR.renderLedger(c); + c.querySelectorAll('[data-o]').forEach((el) => el.addEventListener('click', () => { const o = T.obs.find((x) => x.id === el.dataset.o); if (o.lat) Cam.flyTo({ lat: o.lat - 0.004, lon: o.lon - 0.002, range: 0.0012, tilt: 1.15, heading: 0.4, shiftY: APP.isWide() ? 0 : 0.6 }); GE.world.setTime(o.at); APP.toast(`${ic('hash', 'sm')} ${o.id} · sha256 ${o.hash.slice(0, 16)}…`); })); +}; +const confSVG = (h) => { const w = 200; return ``; }; +THR.hypHTML = function (hy) { + const p = P[hy.by] || P.ai; + return `
    ${hy.ai ? `${ic('sparkles', 'xs')}` : `${p.h[0].toUpperCase()}`}${hy.ai ? 'AI proposal' : '@' + p.h}${hy.id}${hy.status}
    +
    ${esc(hy.text)}
    +
    confidence${confSVG(hy)}${Math.round(hy.conf * 100)}%
    +
    ${hy.cites.map((x) => `${x}`).join('')}${hy.prompt ? `prompt: “${esc(hy.prompt)}” · ${esc(hy.model || 'claude')}` : ''}
    +
    `; +}; +THR.renderHyps = function () { + const { T } = THR.ws; const host = $('#hypList'); if (!host) return; + host.innerHTML = T.hyps.map(THR.hypHTML).join(''); + host.querySelectorAll('[data-h]').forEach((el) => el.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', async () => { + const hy = T.hyps.find((x) => x.id === el.dataset.h); const v = +b.dataset.v; haptic(5); + if (v === 0) { APP.toast(`${ic('bubble', 'sm')} Cite ${hy.id} in a new hypothesis`); return; } + hy.myVote = hy.myVote === v ? 0 : v; await THR.append(T, 'vote', 'you', { target: hy.id, agree: v === 1, retract: hy.myVote === 0 }); + THR.renderHyps(); APP.toast(`${ic('link', 'sm')} Vote recorded in the ledger`); + }))); +}; +THR.bindComposer = function () { + const ta = $('#aiIn'); const send = $('#aiSend'); if (!ta) return; + $$('#composer .sug button').forEach((b) => b.addEventListener('click', () => { ta.value = b.textContent; ta.focus(); })); + ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send.click(); } }); + send.addEventListener('click', () => { const q = ta.value.trim() || THR.ws.T.suggestions[0]; ta.value = ''; THR.refine(q); }); +}; +THR.refine = async function (q) { + const { T } = THR.ws; const send = $('#aiSend'); if (send) send.disabled = true; + const id = 'H' + (T.hyps.length + 1); + const hy = { id, by: 'ai', ai: true, text: '', conf: 0.5, ci: [0.35, 0.65], cites: [], agree: 0, dispute: 0, status: 'proposed', prompt: q, model: '…' }; + T.hyps.push(hy); THR.renderHyps(); + const card = $(`[data-h="${id}"]`); card?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + const textEl = () => $(`[data-h="${id}"] [data-text]`); + const promptStr = `You are helping a group of people reconstruct a real-world event from evidence, in an app that keeps observations (immutable) separate from hypotheses (claims).\nEvent: ${T.title} — ${T.place} (${T.date}). Some witness posts are illustrative.\nObservations:\n${T.obs.map((o) => `[${o.id}] ${o.t} — ${o.src}, ${new Date(o.at).toISOString().slice(0, 16)}Z (${o.meta.join('; ')})`).join('\n')}\nCurrent hypotheses:\n${T.hyps.filter((x) => x.id !== id).map((x) => `[${x.id}] ${x.text} (confidence ${x.conf})`).join('\n')}\nRequest from a participant: "${q}"\nReply with ONE new proposed hypothesis in at most 70 words. Cite observation IDs in square brackets like [O3]. Do not invent new observations. End with a final line exactly like "Confidence: 0.xx".`; + let out = '', model = 'scripted fallback (no AI available here)'; + const render = (txt) => { out = txt; const el = textEl(); if (el) el.textContent = txt.replace(/\n?Confidence:.*$/s, '').trim() || '…'; }; + let sample = null; + try { sample = window.claude?.use ? await window.claude.use('sample') : null; } catch { sample = null; } + if (sample) { + try { const r = await sample(promptStr, { onText: ({ text }) => render(text) }); render(r?.text ?? out); model = 'claude · window.claude sample'; } + catch (e) { sample = null; } + } + if (!sample) { const full = T.fallback; for (let i = 1; i <= full.length; i += 3) { render(full.slice(0, i)); await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 12)); } render(full); } + const cm = /Confidence:\s*([0-9.]+)/i.exec(out); hy.conf = cm ? clamp(parseFloat(cm[1]), 0.05, 0.95) : 0.55; hy.ci = [Math.max(0, hy.conf - 0.16), Math.min(1, hy.conf + 0.14)]; + hy.cites = [...new Set((out.match(/\[(O\d+)\]/g) || []).map((x) => x.slice(1, -1)))]; hy.text = out.replace(/\n?Confidence:.*$/s, '').trim(); hy.model = model; + const e = await THR.append(T, 'ai.proposal', 'claude', { id, prompt: q, model, inputs: hy.cites, output: hy.text, diff: '+' + id }); + THR.renderHyps(); if (send) send.disabled = false; + APP.toast(`${ic('sparkles', 'sm')} Proposed ${id} · ledger #${e.i} · ${e.hash.slice(0, 10)}…`, 3000); +}; + +/* narrative */ +THR.renderNarr = function (c) { + const { T } = THR.ws; + c.innerHTML = `
    ${ic('play', 'xs')} Stratum 3 · Narrative — a camera path through space-time
    ${fmt.utc(GE.world.simMs)}${T.beats.length} beats
    ${T.beats.map((b, i) => `
    ${b.t}
    UTC

    ${esc(b.text)}

    ${b.cites.map((x) => `${x}`).join('')}
    `).join('')}
    `; + $('#play').addEventListener('click', () => (THR.ws.playing ? THR.stop() : THR.play())); + c.querySelectorAll('[data-b]').forEach((el) => el.addEventListener('click', () => THR.goBeat(+el.dataset.b))); +}; +THR.goBeat = function (i, dur) { const { T } = THR.ws; const b = T.beats[i]; THR.ws.beat = i; Cam.flyTo({ ...b.cam, shiftY: APP.isWide() ? 0 : 0.6 }, { duration: dur ?? 2.2 }); GE.world.setTime(b.at); $$('.beat').forEach((x) => x.classList.toggle('on', +x.dataset.b === i)); }; +THR.play = function () { + const ws = THR.ws; ws.playing = true; const T = ws.T; const per = 3.6; const t0 = performance.now(); let last = -1; + const btn = $('#play'); if (btn) btn.innerHTML = ic('pause'); + const step = () => { + if (!THR.ws || !THR.ws.playing) return; const el = (performance.now() - t0) / 1000; const total = per * T.beats.length; + const f = clamp(el / total, 0, 1); const bi = Math.min(T.beats.length - 1, Math.floor(el / per)); + if (bi !== last) { last = bi; THR.goBeat(bi, per * 0.9); } + const a = T.beats[bi], b = T.beats[Math.min(T.beats.length - 1, bi + 1)]; const u = clamp((el - bi * per) / per, 0, 1); + GE.world.setTime(lerp(a.at, b.at, bi === T.beats.length - 1 ? 0 : u)); + const p = $('#narP'); if (p) p.style.width = (f * 100).toFixed(1) + '%'; const nt = $('#narT'); if (nt) nt.textContent = fmt.utc(GE.world.simMs); + if (f >= 1) { THR.stop(); return; } requestAnimationFrame(step); + }; + step(); +}; +THR.stop = function () { if (!THR.ws) return; THR.ws.playing = false; const btn = $('#play'); if (btn) btn.innerHTML = ic('play'); }; + +/* ledger */ +THR.renderLedger = function (c) { + const { T } = THR.ws; const L = THR.ledgers[T.id] || []; + c.innerHTML = `
    ${ic('link', 'xs')} Ledger — SHA-256 hash chain · ${L.length} entries
    ${L.map((e) => `
    #${e.i}${esc(e.type)} · @${esc(e.actor)}
    ${e.hash.slice(0, 20)}… ← ${e.prev.slice(0, 8)}…
    ${esc(JSON.stringify({ i: e.i, ts: new Date(e.ts).toISOString(), type: e.type, actor: e.actor, payload: e.payload, digest: e.digest, prev: e.prev, hash: e.hash }, null, 1))}
    `).join('
    ')}
    `; + c.querySelectorAll('.le .lh').forEach((x) => x.addEventListener('click', () => x.parentElement.classList.toggle('open'))); + $('#lv').addEventListener('click', async () => { + c.querySelectorAll('[data-vk]').forEach((x) => { x.className = 'vk'; x.innerHTML = ''; }); + const ok = await THR.verify(L, async (e, good) => { const el = c.querySelector(`.le[data-i="${e.i}"] [data-vk]`); if (el) { el.className = 'vk ' + (good ? 'ok' : 'bad'); el.innerHTML = ic(good ? 'check' : 'x', 'xs'); el.scrollIntoView({ block: 'nearest' }); } await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 45)); }); + APP.toast(ok ? `${ic('check', 'sm')} Chain verified · ${L.length} links intact` : `${ic('x', 'sm')} Chain broken at #3 — a payload no longer matches its digest`, 2800); haptic(ok ? 10 : [20, 60, 20]); + }); + $('#lt').addEventListener('click', () => { const e = L[3]; if (!e) return; if (THR._tampered) { e.payload = THR._tampered; THR._tampered = null; } else { THR._tampered = e.payload; e.payload = { ...e.payload, title: (e.payload.title || e.payload.text || '') + ' (edited)' }; } THR.renderLedger(c); APP.toast(THR._tampered ? 'Entry #3 edited locally — now verify' : 'Entry #3 restored'); }); + $('#lx').addEventListener('click', () => { try { const blob = new Blob([JSON.stringify(L, null, 1)], { type: 'application/json' }); const a = h('a', { href: URL.createObjectURL(blob), download: `ledger-${T.id}.json` }); document.body.append(a); a.click(); a.remove(); } catch {} APP.toast(`${ic('share', 'sm')} Ledger exported as JSON`); }); +}; diff --git a/mobile/vision/src/js/67-you.js b/mobile/vision/src/js/67-you.js new file mode 100644 index 000000000..d40cf1569 --- /dev/null +++ b/mobile/vision/src/js/67-you.js @@ -0,0 +1,68 @@ +/* You: captures and their seals (+ verify inspector), holograms left in places, followed situations, + alert radius, rendering quality, the concept marker and credits. */ +const YOU = (GE.you = { radius: store.get('radius', 250), follows: store.get('follows', { cyclone: true, koshi: true, aurora: true, iss: false, launch: true }) }); +YOU.render = function () { + const v = $('#v-you'); const st = v.scrollTop; const W = GE.world; + const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')]; + v.innerHTML = `
    +
    ${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}

    You

    +
    Y

    Your window

    Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}
    ${ic('sparkles', 'xs')} Concept · illustrative data
    +
    ${caps.length}captures
    ${holos.length}holograms
    ${Object.values(YOU.follows).filter(Boolean).length}following
    ${caps.length ? '100%' : '—'}verified
    +

    Captures

    sealed on this device
    +
    ${caps.length ? caps.map((c, i) => `
    ${esc(c.place)}${fmt.utc(c.at)}
    ${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}
    `).join('') : `
    No captures yet — open Capture › Witness and press the shutter.
    `}
    +

    Holograms

    left in places
    +
    ${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `
    ${ic('cube')}${esc(x.title)}${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}${x.empty ? '' : ``}
    `).join('')}
    +

    Following

    alerts
    +
    ${Object.entries(YOU.follows).map(([id, on]) => { const s = GE.SIT.byId[id]; if (!s) return ''; return `
    ${ic(GE.FAM[s.fam].icon)}${esc(s.title.split(' — ')[0])}${esc(s.place)}
    `; }).join('')}
    +

    Alert radius

    ${YOU.radius} km
    +
    10 km2,000 km
    +

    Rendering

    adaptive
    +
    ${ic('gauge')}Resolution scaleDrops when frames take > 20 ms for 2 s
    +
    ${ic('cpu')}Frame timePost chain: atmosphere → FXAA → half-res bloom → grade
    +
    ${ic('sparkles')}Bloom
    +
    ${ic('frame')}Film grain
    +
    ${ic('gyro')}Reduced motionFollows your system setting${prefersReduced() ? 'On' : 'Off'}
    +

    About

    vision mockup
    +

    ${ic('sparkles', 'xs')} Concept · illustrative data

    +

    God’s Eye — the window through which you see the world, and the eye through which the world sees itself. This is a design vision: every flight, ship, satellite, quake, fire, cyclone, camera, person, handle, post and reconstruction claim here is synthetic or fictional, placed in real geography.

    +

    Imagery: NASA Earth Observatory / Visible Earth — Blue Marble: Next Generation (cloudless day), Black Marble 2016 (night lights), Blue Marble cloud mosaic, land/water mask and topography. Public domain, courtesy of NASA.

    +

    Real crypto: SHA-256 (WebCrypto) over every rendered capture and ledger entry; ECDSA P-256 signatures with a non-extractable key generated on this device.

    +

    Renderer: hand-written WebGL2 — ray-traced planet with single-scatter atmosphere, day/night terminator from the real sun position, ocean glint, clouds, stars, procedural cities and terrain, GPU-propagated flights and orbits.

    `; + v.scrollTop = st; + v.querySelectorAll('[data-c]').forEach((el) => el.addEventListener('click', () => YOU.inspect(+el.dataset.c))); + v.querySelectorAll('[data-f]').forEach((el) => el.addEventListener('click', () => { const id = el.dataset.f; YOU.follows[id] = !YOU.follows[id]; el.classList.toggle('on', YOU.follows[id]); el.setAttribute('aria-checked', YOU.follows[id]); store.set('follows', YOU.follows); haptic(5); })); + v.querySelectorAll('[data-q]').forEach((el) => el.addEventListener('click', () => { const k = el.dataset.q; W.quality[k] = !W.quality[k]; el.classList.toggle('on', W.quality[k]); haptic(5); })); + v.querySelectorAll('[data-fly]').forEach((el) => el.addEventListener('click', () => { const [la, lo] = el.dataset.fly.split(',').map(Number); APP.setTab('globe'); Cam.flyTo({ lat: la, lon: lo, range: 0.002, tilt: 1.1 }); })); + const rad = $('#rad'); rad.addEventListener('input', () => { YOU.radius = +rad.value; $('#radV').textContent = YOU.radius + ' km'; store.set('radius', YOU.radius); YOU.drawRadius(); }); + YOU.drawRadius(); +}; +YOU.drawRadius = function () { + const svg = $('#radSvg'); if (!svg) return; const W = GE.world; const me = GE.ME; const r = YOU.radius; + const spanKm = Math.max(600, r * 2.6); const sx = 300 / spanKm, sy = 120 / (spanKm * 0.4); + const P2 = (la, lo) => [150 + (lo - me.lon) * 111 * Math.cos(me.lat * D2R) * sx, 60 - (la - me.lat) * 111 * sy]; + let s = ``; + for (const c of GE.CITY_V) { const [x, y] = P2(c.lat, c.lon); if (x > -5 && x < 305 && y > -5 && y < 125) s += ``; } + let n = 0; for (const s0 of GE.SIT.list) { const d = geo.dist(me.lat, me.lon, s0.lat, s0.lon) * EARTH_KM; const [x, y] = P2(s0.lat, s0.lon); if (d < r) n++; if (x > 0 && x < 300 && y > 0 && y < 120) s += ``; } + for (const q of GE.QUAKES) { const d = geo.dist(me.lat, me.lon, q.lat, q.lon) * EARTH_KM; if (d < r) n++; } + s += `${esc(me.name)}`; + svg.innerHTML = s; const nn = $('#radN'); if (nn) nn.textContent = `${n} alerts in range this week`; +}; +YOU.tick = function () { const a = $('#qScale'); if (a) a.textContent = `${GE.gov.scale.toFixed(2)}× · ${GE.gov.w}×${GE.gov.h}`; const b = $('#qFps'); if (b) b.textContent = `${GE.gov.ms.toFixed(1)} ms`; }; +YOU.inspect = async function (i) { + const c = GE.captures[i]; if (!c) return; const el = $('#inspector'); + el.innerHTML = `
    +
    Verify capture
    ${esc(c.place)} · ${fmt.utc(c.at)}
    +
    ${['Media hash recomputed (SHA-256)', 'Bundle hash recomputed', 'Signature valid — ECDSA P-256, device public key', 'IMU agrees with the image horizon (±0.8°)', 'GPS track agrees with capture time', 'Chain link to previous bundle'].map((t, k) => `
    ${ic('check', 'xs')}
    ${t}
    `).join('')}
    +
    pubkey x ${c.jwk?.x || '—'}
    pubkey y ${c.jwk?.y || '—'}
    +

    In a native build the key lives in the Secure Enclave / StrongBox and the bundle is written as a C2PA manifest.

    `; + el.classList.add('on'); + $('#insX').addEventListener('click', () => el.classList.remove('on')); + el.addEventListener('click', (e) => { if (e.target === el) el.classList.remove('on'); }); + const mark = async (k, ok, txt) => { await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 260)); const s = $(`#vsteps [data-k="${k}"]`); if (!s) return; s.classList.add('on'); if (ok) s.classList.add('done'); else s.querySelector('.ck').style.cssText = 'background:var(--live);box-shadow:none;color:#fff'; s.querySelector('[data-h]').textContent = txt; haptic(3); }; + const mh = await sha256(c.bytes); await mark(0, mh === c.mediaHash, mh); + const bh = await sha256(canon({ media: mh, session: c.session, context: c.context, prev: c.prev })); await mark(1, bh === c.hash, bh); + const ok = await GE.crypto.verifyHex(c.hash, c.sig); await mark(2, ok, ok ? (c.sig || '').slice(0, 64) + '…' : 'no signature available in this browser'); + await mark(3, true, `roll ${c.session.imu[0].g}° · pitch ${c.session.imu[0].b}° vs rendered horizon`); + await mark(4, true, `${c.session.gps.length} fixes within ${(c.session.end - c.session.start) / 1000} s of the shutter`); + const prev = i > 0 ? GE.captures[i - 1].hash : '0'.repeat(64); await mark(5, prev === c.prev, `prev ${c.prev.slice(0, 32)}…`); +}; diff --git a/mobile/vision/src/js/90-main.js b/mobile/vision/src/js/90-main.js new file mode 100644 index 000000000..7387c310f --- /dev/null +++ b/mobile/vision/src/js/90-main.js @@ -0,0 +1,103 @@ +/* Boot, render loop, adaptive-DPR governor, cold open. */ +const HASH = (() => { const o = {}; try { (location.hash || '').slice(1).split('&').forEach((kv) => { const [k, v] = kv.split('='); if (k) o[decodeURIComponent(k)] = v == null ? true : decodeURIComponent(v); }); } catch {} return o; })(); +GE.HASH = HASH; +const gov = (GE.gov = { levels: [1, 0.85, 0.72, 0.6, 0.5], li: 0, scale: 1, ms: 16, w: 0, h: 0, acc: 0, n: 0, slow: 0, fast: 0, pinned: HASH.dpr ? parseFloat(HASH.dpr) : null }); +let chain = null; const canvas = $('#gl'); +function sizeCanvas() { + const cw = canvas.clientWidth || innerWidth, chh = canvas.clientHeight || innerHeight; + const dpr = Math.min(devicePixelRatio || 1, 2); + const s = gov.pinned || dpr * gov.levels[gov.li]; gov.scale = s; + const w = Math.max(2, Math.round(cw * s)), hh = Math.max(2, Math.round(chh * s)); + if (canvas.width !== w || canvas.height !== hh) { canvas.width = w; canvas.height = hh; } + if (!chain || chain.w !== w || chain.h !== hh) { chain && chain.dispose(); chain = GL.chain(w, hh); } + gov.w = w; gov.h = hh; return [cw, chh]; +} +function governor(dt) { + if (gov.pinned) return; + gov.ms = lerp(gov.ms, dt * 1000, 0.08); + if (gov.ms > 21) { gov.slow += dt; gov.fast = 0; } else if (gov.ms < 13.5) { gov.fast += dt; gov.slow = 0; } else { gov.slow = Math.max(0, gov.slow - dt); gov.fast = 0; } + if (gov.slow > 2 && gov.li < gov.levels.length - 1) { gov.li++; gov.slow = 0; bus.emit('quality', { scale: gov.scale, fps: 1000 / gov.ms }); } + if (gov.fast > 5 && gov.li > 0) { gov.li--; gov.fast = 0; } + W.quality.steps = gov.li >= 3 ? 8 : 12; +} +function globeVisible() { + if (APP.intro) return true; + if (APP.isWide()) return true; + if (APP.tab === 'globe' || APP.tab === 'capture' || APP.mode) return true; + if (APP.tab === 'now') { const v = $('#v-now'); return v.scrollTop < v.querySelector('.now-hero')?.offsetHeight + 40; } + return false; +} +let lastT = performance.now(), lastDyn = 0, lastSec = 0, running = true; +function frame(now) { + if (!running) return; + const dt = Math.min(0.1, Math.max(0.001, (now - lastT) / 1000)); lastT = now; + W.simMs = Date.now() + W.offsetH * 3600000; W.t = (W.simMs - NOW) / 1000; W.sun = astro.sun(W.simMs); + Cam.update(dt); + if (APP.mode === 'situation' && !Cam.anim && GE.sheet.targetShift != null && !APP.isWide()) Cam.shiftY += (GE.sheet.targetShift - Cam.shiftY) * Math.min(1, dt * 7); + if (globeVisible()) { + const [cw, chh] = sizeCanvas(); + if (now - lastDyn > 40) { W.updateDynamic(); lastDyn = now; } + const view = Cam.view(cw, chh); GE.mainView = view; + const ov = GE.renderOverride || {}; + W.render(view, chain, null, { ...ov, focus: W.focus, extraSprites: W.activeExtras?.sprites, extraLines: W.activeExtras?.lines, fade: GE.introFade ?? 1 }); + APP.updateLabels(view); GE.ride.frame(view); GE.capture.frame(view); + } + if (APP.tab === 'globe' || APP.isWide() || APP.mode) GE.gui.ribbonFrame(); + GE.thumbs.step(APP.intro ? 14 : 8); + if (now - lastSec > 1000) { lastSec = now; W.updateCounts(); GE.now.tick(); GE.sheet.tick(); GE.you.tick(); } + governor(dt); + requestAnimationFrame(frame); +} +document.addEventListener('visibilitychange', () => { if (document.hidden) running = false; else if (!running) { running = true; lastT = performance.now(); requestAnimationFrame(frame); } }); + +/* ---------- cold open ---------- */ +function coldOpen() { + const cold = $('#cold'); const app = $('#app'); + const skipAll = HASH.nointro || HASH.tab; + const red = prefersReduced(); + W.sun = astro.sun(Date.now()); const finalCam = APP.heroCam(); + const end = () => { + if (!APP.intro) return; APP.intro = false; GE.introFade = 1; W.setTime(null); + cold.classList.add('out'); app.classList.remove('intro'); setTimeout(() => cold.remove(), 1100); + Cam.cancel(); if (Cam.range > 2.2 && !APP.isWide()) Cam.flyTo(finalCam, { duration: 0.6 }); + Cam.autoSpin = 0.3; APP.updateChrome(); APP.moveLens(); + if (HASH.tab) APP.setTab(HASH.tab, { force: true }); + }; + APP.intro = true; app.classList.add('intro'); APP.updateChrome(); + if (skipAll) { Cam.set(finalCam); end(); return; } + Cam.set({ lat: 8, lon: finalCam.lon + 75, range: 5.8, heading: 0, tilt: 0, shiftY: 0.0, shiftX: 0 }); + const T = red ? 1.2 : 3.4; const t0 = performance.now(); + GE.introFade = 0; + if (!red) Cam.flyTo(finalCam, { duration: T }); + else Cam.set(finalCam); + const tick = () => { + if (!APP.intro) return; const u = clamp((performance.now() - t0) / 1000 / T, 0, 1); + GE.introFade = ease.out(clamp(u * 2.2, 0, 1)); + if (!red) W.setTime(Date.now() - (1 - ease.outQuint(u)) * 9 * 3600000); + if (u > 0.12) cold.classList.add('s1'); if (u > 0.36) cold.classList.add('s2'); + if (u >= 1) { setTimeout(end, red ? 200 : 500); return; } requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + $('#skip').addEventListener('click', (e) => { e.stopPropagation(); Cam.set(finalCam); end(); }); + cold.addEventListener('click', () => { Cam.set(finalCam); end(); }); +} + +/* ---------- boot ---------- */ +async function boot() { + const gl = GL.init(canvas); + if (!gl) { document.body.insertAdjacentHTML('beforeend', '
    This vision needs WebGL2. Try a recent browser on a phone, tablet or desktop.
    '); return; } + canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); running = false; APP.toast('Graphics context lost — reopen to resume', 4000); }); + await W.loadTextures(); + W.buildStatic(); W.buildMovers(); W.updateCounts(); W.updateDynamic(); + W.situationPins = GE.SIT.list.map(GE.SIT.pin).filter(Boolean); + APP.initShell(); GE.now.render(); GE.threads.render(); GE.you.render(); GE.gui.init(); GE.sheet.initDrag(); + Cam.attach(canvas, () => GE.mainView || Cam.view(canvas.clientWidth, canvas.clientHeight)); + sizeCanvas(); + coldOpen(); + requestAnimationFrame(frame); + GE.threads.init().then(() => { if (!GE.threads.ws) GE.threads.render(); }); + GE.crypto.KEY.get().then(() => GE.you.render()); + if (HASH.open) setTimeout(() => GE.sheet.open(HASH.open, {}), 300); + GE.ready = true; +} +boot().catch((e) => { console.error(e); });