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

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

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
/** Rear camera: preview, still photo (JPEG) and short video (MediaRecorder). */
export function createCamera(video) {
let stream = null;
let recorder = null;
let chunks = [];
async function start() {
if (stream) return stream;
if (!navigator.mediaDevices?.getUserMedia)
throw new Error('Camera API unavailable (needs https)');
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1440 },
},
});
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await video.play().catch(() => {});
return stream;
}
function stop() {
if (recorder && recorder.state !== 'inactive') recorder.stop();
stream?.getTracks().forEach((t) => t.stop());
stream = null;
video.srcObject = null;
}
function info() {
const t = stream?.getVideoTracks()[0];
if (!t) return null;
const s = t.getSettings?.() || {};
return {
label: t.label || 'camera',
width: s.width,
height: s.height,
facingMode: s.facingMode || null,
frameRate: s.frameRate ? Math.round(s.frameRate) : null,
};
}
/** Grab the current frame as a JPEG blob at the sensor's resolution. */
async function photo() {
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h) throw new Error('Camera not ready');
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(video, 0, 0, w, h);
return new Promise((resolve, reject) =>
c.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Encode failed'))),
'image/jpeg',
0.92,
),
);
}
function canRecord() {
return !!stream && typeof MediaRecorder !== 'undefined';
}
function startVideo() {
if (!canRecord()) throw new Error('Video recording unavailable');
const types = [
'video/mp4;codecs=avc1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
];
const mimeType = types.find((t) => MediaRecorder.isTypeSupported?.(t));
chunks = [];
recorder = new MediaRecorder(
stream,
mimeType ? { mimeType, videoBitsPerSecond: 4e6 } : undefined,
);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.start(250);
}
function stopVideo() {
return new Promise((resolve) => {
if (!recorder || recorder.state === 'inactive') return resolve(null);
recorder.onstop = () =>
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
recorder.stop();
});
}
return {
start,
stop,
info,
photo,
startVideo,
stopVideo,
canRecord,
get active() {
return !!stream;
},
};
}
+93
View File
@@ -0,0 +1,93 @@
/** Minimal promise wrapper over the device's IndexedDB for keys and captures. */
const DB_NAME = 'gev-mobile';
const DB_VERSION = 1;
let dbp = null;
export function openDb() {
if (dbp) return dbp;
dbp = new Promise((resolve, reject) => {
if (!('indexedDB' in globalThis)) {
reject(new Error('IndexedDB is unavailable'));
return;
}
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('keys'))
db.createObjectStore('keys', { keyPath: 'id' });
if (!db.objectStoreNames.contains('captures')) {
const s = db.createObjectStore('captures', { keyPath: 'id' });
s.createIndex('seq', 'seq');
}
if (!db.objectStoreNames.contains('media'))
db.createObjectStore('media', { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
dbp.catch(() => (dbp = null));
return dbp;
}
function wrap(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function get(store, key) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).get(key));
}
export async function put(store, value) {
const db = await openDb();
return wrap(db.transaction(store, 'readwrite').objectStore(store).put(value));
}
export async function del(store, key) {
const db = await openDb();
return wrap(
db.transaction(store, 'readwrite').objectStore(store).delete(key),
);
}
export async function all(store) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).getAll());
}
/** Write a capture bundle and its media atomically. */
export async function putCapture(bundle, mediaBlob) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(['captures', 'media'], 'readwrite');
tx.objectStore('captures').put(bundle);
tx.objectStore('media').put({ id: bundle.id, blob: mediaBlob });
tx.oncomplete = () => resolve(bundle);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function lastCapture() {
const db = await openDb();
return new Promise((resolve, reject) => {
const req = db
.transaction('captures')
.objectStore('captures')
.index('seq')
.openCursor(null, 'prev');
req.onsuccess = () => resolve(req.result?.value || null);
req.onerror = () => reject(req.error);
});
}
export async function captureBySeq(seq) {
const db = await openDb();
return wrap(
db.transaction('captures').objectStore('captures').index('seq').get(seq),
);
}
+384
View File
@@ -0,0 +1,384 @@
/**
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
* Enclave + C2PA flow:
*
* media → sha256
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
* context { device, ua, screen, timezone, network-time-offset, … }
* prev hash of this device's previous bundle (hash chain)
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
* is generated non-extractable and kept in IndexedDB.
*
* None of this proves truth. It raises the cost of lying.
*/
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
const subtle = globalThis.crypto?.subtle;
const enc = new TextEncoder();
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
export function canonical(value) {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
return JSON.stringify(value ?? null);
}
if (Array.isArray(value))
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
const keys = Object.keys(value)
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
const normalize = (v) => JSON.parse(canonical(v));
export const toHex = (buf) =>
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
const fromHex = (hex) =>
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const fromB64url = (s) => {
const b = atob(
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
);
return Uint8Array.from(b, (c) => c.charCodeAt(0));
};
export async function sha256Hex(data) {
const bytes =
typeof data === 'string'
? enc.encode(data)
: data instanceof Blob
? await data.arrayBuffer()
: data;
return toHex(await subtle.digest('SHA-256', bytes));
}
function assertCrypto() {
if (!subtle)
throw new Error('WebCrypto needs a secure context (https or localhost)');
}
/** The device signing key: generated once, private half non-extractable. */
export async function deviceKey() {
assertCrypto();
const stored = await get('keys', 'device').catch(() => null);
if (stored?.privateKey && stored?.publicJwk) return stored;
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', pair.publicKey);
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
const rec = {
id: 'device',
privateKey: pair.privateKey,
publicKey: pair.publicKey,
publicJwk,
keyId,
createdAt: new Date().toISOString(),
extractable: pair.privateKey.extractable,
};
await put('keys', rec);
return rec;
}
/** Estimate the device clock's offset from the server's Date header (±1 s). */
export async function networkTimeOffset() {
try {
const t0 = Date.now();
const r = await fetch(location.pathname, {
method: 'HEAD',
cache: 'no-store',
});
const t1 = Date.now();
const d = Date.parse(r.headers.get('date') || '');
if (!Number.isFinite(d)) return null;
return Math.round(d + 500 - (t0 + t1) / 2);
} catch {
return null;
}
}
/**
* Seal a capture.
* @param {{ media: Blob, session: object, context: object }} input
* @returns {Promise<object>} the stored bundle
*/
export async function seal({ media, session, context }) {
assertCrypto();
const key = await deviceKey();
const mediaHash = await sha256Hex(media);
const last = await lastCapture().catch(() => null);
const seq = (last?.seq || 0) + 1;
const core = normalize({
v: 1,
seq,
media: {
sha256: mediaHash,
type: media.type || 'application/octet-stream',
bytes: media.size,
},
session,
context,
prev: last?.hash || null,
keyId: key.keyId,
publicKey: key.publicJwk,
sealedAt: new Date().toISOString(),
});
const hash = await sha256Hex(canonical(core));
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
const bundle = {
...core,
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
hash,
alg: 'ES256',
signature: b64url(sig),
};
await putCapture(bundle, media);
return bundle;
}
const median = (a) => {
if (!a.length) return NaN;
const s = [...a].sort((x, y) => x - y);
return s[Math.floor(s.length / 2)];
};
/** Core fields covered by the hash (everything but the outputs). */
function coreOf(bundle) {
const { id, hash, alg, signature, ...core } = bundle;
void id;
void hash;
void alg;
void signature;
return core;
}
/**
* Verify a bundle: hash, signature, chain, sensor plausibility.
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
*/
export async function verify(bundle, mediaBlob) {
assertCrypto();
const checks = [];
const add = (id, label, status, detail) =>
checks.push({ id, label, status, detail });
// 1. Media hash
if (mediaBlob) {
const h = await sha256Hex(mediaBlob);
add(
'media',
'Media hash',
h === bundle.media?.sha256 ? 'pass' : 'fail',
h === bundle.media?.sha256
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
);
} else
add('media', 'Media hash', 'warn', 'Media not available on this device');
// 2. Bundle hash
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
const hashOk = recomputed === bundle.hash;
add(
'hash',
'Bundle hash',
hashOk ? 'pass' : 'fail',
hashOk
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
: 'Bundle was modified after sealing',
);
// 3. Signature
try {
const pub = await subtle.importKey(
'jwk',
{ ...bundle.publicKey, ext: true },
ALG,
true,
['verify'],
);
const ok = await subtle.verify(
SIGN,
pub,
fromB64url(bundle.signature),
fromHex(bundle.hash),
);
const mine = await deviceKey().catch(() => null);
const same = mine?.keyId === bundle.keyId;
add(
'signature',
'Signature',
ok ? (same ? 'pass' : 'warn') : 'fail',
ok
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
: 'Signature does not verify against the embedded public key',
);
} catch (err) {
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
}
// 4. Chain
if (bundle.seq === 1)
add(
'chain',
'Hash chain',
bundle.prev === null ? 'pass' : 'fail',
bundle.prev === null
? 'Genesis capture for this device key'
: 'First capture claims a predecessor',
);
else {
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
if (!prev)
add(
'chain',
'Hash chain',
'warn',
`Capture #${bundle.seq - 1} is not on this device`,
);
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
add(
'chain',
'Hash chain',
'pass',
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
);
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
}
// 5. IMU plausibility
const imu = bundle.session?.imu || [];
if (imu.length < 5)
add(
'imu',
'Motion trace',
'warn',
imu.length
? `Only ${imu.length} samples`
: 'No motion sensors were available',
);
else {
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
const rate = imu.length / Math.max(0.001, dur);
let mono = true;
for (let i = 1; i < imu.length; i += 1)
if (imu[i].t < imu[i - 1].t) mono = false;
const mags = imu
.filter((s) => Number.isFinite(s.ax))
.map((s) => Math.hypot(s.ax, s.ay, s.az));
const g = median(mags);
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
const sd = Math.sqrt(
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
);
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
const alive = sd > 0.004;
// Pitch from gravity should agree with the orientation β.
let tiltAgree = null;
const withBoth = imu.filter(
(s) =>
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
);
if (withBoth.length > 4) {
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const diffs = withBoth.map((s) => {
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
return Math.min(d(pitchG, s.β), d(flipped, s.β));
});
tiltAgree = median(diffs) < 20;
}
const status =
!mono || gravityOk === false
? 'fail'
: alive && tiltAgree !== false
? 'pass'
: 'warn';
add(
'imu',
'Motion trace',
status,
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
);
}
// 6. GPS plausibility
const gps = bundle.session?.gps || [];
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
else {
const s0 = Date.parse(bundle.session.start);
const s1 = Date.parse(bundle.session.end);
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
let maxSpeed = 0;
for (let i = 1; i < gps.length; i += 1) {
const a = gps[i - 1];
const b = gps[i];
const dt = (b.t - a.t) / 1000;
if (dt > 0.5) {
const R = 6371e3;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
}
}
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
const last = gps[gps.length - 1];
// The clock's timezone should be roughly where the fix says we are.
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
const solarH = last.lon / 15;
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
const speedOk = maxSpeed < 300;
const status =
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
add(
'gps',
'Location',
status,
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
);
}
// 7. Heading
const hd = bundle.session?.heading;
if (hd && Number.isFinite(hd.deg))
add(
'heading',
'Heading',
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
);
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
// 8. Clock
const off = bundle.context?.networkTimeOffsetMs;
const sealedVsEnd =
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
add(
'clock',
'Clock',
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
? 'warn'
: clockOk
? 'pass'
: 'warn',
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
);
return { ok: !checks.some((c) => c.status === 'fail'), checks };
}
+371
View File
@@ -0,0 +1,371 @@
/**
* Device sensors for Look and Witness: motion/orientation (with iOS
* permission on a user gesture), compass heading, camera pose, GPS watch,
* and a session recorder for the sealed IMU trace.
*/
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
/** iOS 13+: must be called from a user gesture. */
export async function requestMotionPermission() {
const asks = [];
if (
typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function'
)
asks.push(DeviceOrientationEvent.requestPermission());
if (
typeof DeviceMotionEvent !== 'undefined' &&
typeof DeviceMotionEvent.requestPermission === 'function'
)
asks.push(DeviceMotionEvent.requestPermission());
if (!asks.length)
return typeof DeviceOrientationEvent === 'undefined'
? 'unsupported'
: 'granted';
try {
const res = await Promise.all(asks);
return res.every((r) => r === 'granted') ? 'granted' : 'denied';
} catch {
return 'denied';
}
}
/** W3C Z-X'-Y'' rotation matrix (device → East-North-Up). */
export function rotation(alpha, beta, gamma) {
const a = alpha * D2R;
const b = beta * D2R;
const g = gamma * D2R;
const cA = Math.cos(a);
const sA = Math.sin(a);
const cB = Math.cos(b);
const sB = Math.sin(b);
const cG = Math.cos(g);
const sG = Math.sin(g);
return [
[cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],
[cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],
[-cB * sG, sB, cB * cG],
];
}
/** Back-camera pose in ENU: forward / right / up unit vectors + azimuth/elevation. */
export function cameraPose(alpha, beta, gamma, screenAngle = 0) {
const R = rotation(alpha, beta, gamma);
const col = (i) => [R[0][i], R[1][i], R[2][i]];
const fwd = col(2).map((v) => -v);
let right = col(0);
let up = col(1);
if (screenAngle) {
// Landscape: rotate the screen basis about the forward axis.
const s = screenAngle * D2R;
const r2 = right.map((v, i) => v * Math.cos(s) + up[i] * Math.sin(s));
const u2 = up.map((v, i) => v * Math.cos(s) - right[i] * Math.sin(s));
right = r2;
up = u2;
}
const az = (Math.atan2(fwd[0], fwd[1]) * R2D + 360) % 360;
const el = Math.asin(Math.max(-1, Math.min(1, fwd[2]))) * R2D;
const roll = Math.atan2(right[2], up[2]) * R2D;
return { fwd, right, up, az, el, roll };
}
/** Pose from a simulated yaw/pitch (desktop drag-to-look). */
export function poseFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
const fwd = [
Math.cos(e) * Math.sin(a),
Math.cos(e) * Math.cos(a),
Math.sin(e),
];
const right = [Math.cos(a), -Math.sin(a), 0];
const up = [
right[1] * fwd[2] - right[2] * fwd[1],
right[2] * fwd[0] - right[0] * fwd[2],
right[0] * fwd[1] - right[1] * fwd[0],
];
return { fwd, right, up, az: ((az % 360) + 360) % 360, el, roll: 0 };
}
export function createSensors() {
const state = {
orientation: null, // {alpha,beta,gamma,absolute}
motion: null, // {ax,ay,az,lx,ly,lz,rr}
compass: null, // degrees, iOS webkitCompassHeading
compassAccuracy: null,
fix: null, // {t,lat,lon,acc,alt,heading,speed}
geoError: null,
hasMotion: false,
hasOrientation: false,
absolute: false,
};
const trace = []; // live |a| for the HUD
let rec = null;
let geoWatch = null;
let alphaOffset = 0;
let listeners = [];
function onOrient(e) {
if (e.alpha == null && e.beta == null) return;
state.hasOrientation = true;
const absolute =
e.absolute === true || e.type === 'deviceorientationabsolute';
if (absolute) state.absolute = true;
if (
!absolute &&
state.absolute &&
e.type === 'deviceorientation' &&
e.webkitCompassHeading == null
)
return;
state.orientation = {
alpha: e.alpha ?? 0,
beta: e.beta ?? 0,
gamma: e.gamma ?? 0,
absolute,
};
if (e.webkitCompassHeading != null) {
state.compass = e.webkitCompassHeading;
state.compassAccuracy = e.webkitCompassAccuracy;
// Align the arbitrary iOS alpha with magnetic north, smoothly.
// az = −α for an upright camera, so α + (raw − compass) points at north.
const raw = cameraPose(e.alpha, e.beta, e.gamma).az;
const target = ((raw - e.webkitCompassHeading + 540) % 360) - 180;
alphaOffset += (((target - alphaOffset + 540) % 360) - 180) * 0.15;
}
if (rec) sample();
}
function onMotion(e) {
const g = e.accelerationIncludingGravity;
const l = e.acceleration;
if (!g || g.x == null) return;
state.hasMotion = true;
state.motion = {
ax: g.x,
ay: g.y,
az: g.z,
lx: l?.x,
ly: l?.y,
lz: l?.z,
rr: e.rotationRate
? [e.rotationRate.alpha, e.rotationRate.beta, e.rotationRate.gamma]
: null,
};
const mag = Math.hypot(g.x, g.y, g.z);
trace.push({ t: performance.now(), v: mag });
while (trace.length && performance.now() - trace[0].t > 5000) trace.shift();
if (rec) sample();
}
let lastSampleT = 0;
function sample() {
const now = performance.now();
if (now - lastSampleT < 15) return; // ≤ ~66 Hz
lastSampleT = now;
const o = state.orientation || {};
const m = state.motion || {};
rec.imu.push({
t: Date.now() - rec.t0Wall,
α: round(o.alpha),
β: round(o.beta),
γ: round(o.gamma),
ax: round(m.ax, 3),
ay: round(m.ay, 3),
az: round(m.az, 3),
});
if (rec.imu.length > 4000) rec.imu.shift();
}
function round(v, d = 2) {
return Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null;
}
function on(target, type, fn) {
target.addEventListener(type, fn, { passive: true });
listeners.push(() => target.removeEventListener(type, fn));
}
function start() {
stop();
if ('ondeviceorientationabsolute' in window)
on(window, 'deviceorientationabsolute', onOrient);
on(window, 'deviceorientation', onOrient);
on(window, 'devicemotion', onMotion);
if ('geolocation' in navigator) {
geoWatch = navigator.geolocation.watchPosition(
(p) => {
state.geoError = null;
state.fix = {
t: p.timestamp || Date.now(),
lat: p.coords.latitude,
lon: p.coords.longitude,
acc: p.coords.accuracy,
alt: p.coords.altitude,
heading: p.coords.heading,
speed: p.coords.speed,
};
if (rec)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
},
(err) => (state.geoError = err.message || 'Location unavailable'),
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 },
);
}
}
function stop() {
listeners.forEach((f) => f());
listeners = [];
if (geoWatch != null) navigator.geolocation.clearWatch(geoWatch);
geoWatch = null;
}
/** Current back-camera pose, or null without orientation sensors. */
function pose(screenAngle = screenOrientationAngle()) {
const o = state.orientation;
if (!o) return null;
const alpha = state.compass != null ? o.alpha + alphaOffset : o.alpha;
return cameraPose(alpha, o.beta, o.gamma, screenAngle);
}
function headingInfo() {
const p = pose();
if (!p) return null;
return {
deg: Math.round(p.az * 10) / 10,
pitch: Math.round(p.el * 10) / 10,
roll: Math.round(p.roll * 10) / 10,
source:
state.compass != null
? 'compass'
: state.absolute
? 'absolute'
: 'relative',
};
}
return {
state,
trace,
start,
stop,
pose,
headingInfo,
/** Begin recording a session (IMU + GPS). Keeps a pre-roll of what's already live. */
beginSession() {
rec = {
t0Wall: Date.now(),
start: new Date().toISOString(),
imu: [],
gps: [],
};
if (state.fix)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return rec;
},
endSession() {
if (!rec) return null;
const r = rec;
rec = null;
// Keep the last 20 s of motion before the shutter.
const cutoff = Date.now() - r.t0Wall - 20000;
const imu = r.imu.filter((s) => s.t >= cutoff);
if (!r.gps.length && state.fix)
r.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return {
start: r.start,
end: new Date().toISOString(),
imu,
gps: r.gps,
heading: headingInfo(),
};
},
get recording() {
return !!rec;
},
};
}
export function screenOrientationAngle() {
const a = screen.orientation?.angle ?? window.orientation ?? 0;
return Number(a) || 0;
}
/* ---------- AR geometry ---------- */
const A = 6378137;
const E2 = 6.69437999014e-3;
function ecef(lat, lon, h) {
const la = lat * D2R;
const lo = lon * D2R;
const N = A / Math.sqrt(1 - E2 * Math.sin(la) ** 2);
return [
(N + h) * Math.cos(la) * Math.cos(lo),
(N + h) * Math.cos(la) * Math.sin(lo),
(N * (1 - E2) + h) * Math.sin(la),
];
}
/** Azimuth, elevation (deg) and slant range (m) from an observer to a target. */
export function lookAngles(obs, tgt) {
const o = ecef(obs.lat, obs.lon, obs.alt || 0);
const t = ecef(tgt.lat, tgt.lon, tgt.altM || 0);
const d = [t[0] - o[0], t[1] - o[1], t[2] - o[2]];
const la = obs.lat * D2R;
const lo = obs.lon * D2R;
const e = -Math.sin(lo) * d[0] + Math.cos(lo) * d[1];
const n =
-Math.sin(la) * Math.cos(lo) * d[0] -
Math.sin(la) * Math.sin(lo) * d[1] +
Math.cos(la) * d[2];
const u =
Math.cos(la) * Math.cos(lo) * d[0] +
Math.cos(la) * Math.sin(lo) * d[1] +
Math.sin(la) * d[2];
const range = Math.hypot(e, n, u);
return {
az: (Math.atan2(e, n) * R2D + 360) % 360,
el: Math.asin(u / range) * R2D,
range,
enu: [e / range, n / range, u / range],
};
}
/** Project an ENU unit direction into screen pixels for a pinhole camera. */
export function projectDir(dir, pose, W, H, vfovDeg) {
const x =
dir[0] * pose.right[0] + dir[1] * pose.right[1] + dir[2] * pose.right[2];
const y = dir[0] * pose.up[0] + dir[1] * pose.up[1] + dir[2] * pose.up[2];
const z = dir[0] * pose.fwd[0] + dir[1] * pose.fwd[1] + dir[2] * pose.fwd[2];
const fy = H / 2 / Math.tan((vfovDeg / 2) * D2R);
if (z <= 0.01)
return { behind: true, x: W / 2 + x * 1e4, y: H / 2 - y * 1e4 };
return { behind: false, x: W / 2 + (x / z) * fy, y: H / 2 - (y / z) * fy, z };
}
export function dirFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
return [Math.cos(e) * Math.sin(a), Math.cos(e) * Math.cos(a), Math.sin(e)];
}
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>God's Eye</title>
<meta
name="description"
content="The window through which you see the world, and the eye through which the world sees itself."
/>
<meta name="theme-color" content="#05070b" />
<meta name="color-scheme" content="dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="God's Eye" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/logo.svg" />
<link rel="preconnect" href="https://server.arcgisonline.com" crossorigin />
<link rel="stylesheet" href="./styles/tokens.css" />
<link rel="stylesheet" href="./styles/shell.css" />
<link rel="stylesheet" href="./styles/feed.css" />
<link rel="stylesheet" href="./styles/sheet.css" />
<link rel="stylesheet" href="./styles/globe-ui.css" />
<link rel="stylesheet" href="./styles/capture.css" />
<link rel="stylesheet" href="./styles/threads.css" />
<link rel="stylesheet" href="./styles/layout.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<div id="app" class="app" data-tab="now" data-booting="true">
<!-- The one persistent globe. Everything else is glass over it. -->
<div id="stage" class="stage" aria-label="Globe">
<div id="globe" class="globe-host"></div>
<div id="globe-veil" class="globe-veil" aria-hidden="true"></div>
<div id="globe-chrome" class="globe-chrome"></div>
</div>
<main id="lead" class="lead">
<section
id="view-now"
class="view view--scroll"
data-view="now"
aria-label="Now"
></section>
<section
id="view-globe"
class="view view--scroll"
data-view="globe"
aria-label="Globe layers"
></section>
<section
id="view-threads"
class="view view--scroll"
data-view="threads"
aria-label="Threads"
hidden
></section>
<section
id="view-you"
class="view view--scroll"
data-view="you"
aria-label="You"
hidden
></section>
</main>
<section
id="view-capture"
class="capture"
data-view="capture"
aria-label="Capture"
hidden
></section>
<div id="sheets" class="sheets"></div>
<nav id="tabbar" class="tabbar" aria-label="Primary"></nav>
</div>
<noscript>
<p class="noscript">God's Eye needs JavaScript to draw the planet.</p>
</noscript>
</body>
</html>
+363
View File
@@ -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=<situation id>
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,
};
+783
View File
@@ -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;
}
+467
View File
@@ -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;
}
+748
View File
@@ -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;
}
+190
View File
@@ -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;
}
}
+508
View File
@@ -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;
}
+1000
View File
@@ -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;
}
}
File diff suppressed because it is too large. Load diff
+365
View File
@@ -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;
}
}
+957
View File
@@ -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 = `
<div class="cap" data-mode="look">
<video class="cap-video" playsinline muted autoplay></video>
<canvas class="cap-ar" aria-hidden="true"></canvas>
<div class="cap-level" aria-hidden="true"><i></i></div>
<div class="cap-top">
<div class="cap-tape glass-strong"><canvas></canvas><span class="cap-caret"></span></div>
<div class="hud glass-strong" aria-live="off">
<div class="hud-grid">
<span>LAT</span><b class="num" data-h="lat">—</b><span>LON</span><b class="num" data-h="lon">—</b>
<span>±</span><b class="num" data-h="acc">—</b><span>HDG</span><b class="num" data-h="hdg">—</b>
<span>PITCH</span><b class="num" data-h="pitch">—</b><span>ROLL</span><b class="num" data-h="roll">—</b>
</div>
<div class="hud-trace"><canvas></canvas><span>IMU |a|</span></div>
<div class="hud-rec"><span class="rec-dot"></span><span data-h="session">Session idle</span></div>
</div>
</div>
<div class="cap-status glass-strong" hidden></div>
<div class="cap-bottom">
<div class="cap-modes" role="tablist" aria-label="Capture mode">
<button role="tab" type="button" data-mode="look" aria-selected="true">Look</button>
<button role="tab" type="button" data-mode="witness" aria-selected="false">Witness</button>
</div>
<div class="cap-controls">
<button class="cap-thumb" type="button" aria-label="Last capture"><span>${icon('seal')}</span></button>
<button class="shutter" type="button" aria-label="Seal a photo. Hold for video"><span class="shutter-ring"></span><span class="shutter-core"></span></button>
<button class="cap-side icon-btn glass-strong" type="button" data-act="recenter" aria-label="Sensors">${icon('scope')}</button>
</div>
<div class="cap-legend"></div>
</div>
<div class="cap-gate" hidden>
<div class="gate-card glass-strong">
<div class="gate-ic">${icon('viewfinder')}</div>
<h2>See the sky. Seal what you see.</h2>
<p>Capture uses three sensors, only while this tab is open.</p>
<ul>
<li>${icon('camera')}<div><b>Camera</b><span>Look overlays aircraft, satellites and ships on the live view. Witness records the photo or clip you seal.</span></div></li>
<li>${icon('north')}<div><b>Motion &amp; compass</b><span>Aligns the overlay with where your phone points, and becomes part of the signed motion trace.</span></div></li>
<li>${icon('location')}<div><b>Location</b><span>Places contacts around you, and stamps each capture with where you stood.</span></div></li>
</ul>
<p class="gate-fine">${icon('lock')} Captures stay on this device. Each one is signed with a key generated here that cannot be exported.</p>
<button class="pill-btn primary gate-go" type="button">Continue</button>
<button class="pill-btn plain gate-skip" type="button">Not now — use a simulated sky</button>
</div>
</div>
<div class="cap-seal" hidden></div>
</div>`;
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]) =>
`<span style="--c:${KIND_COLOR[k] || '#fff'}"><i></i>${n} ${(KIND_LABEL[k] || [k, `${k}s`])[n > 1 ? 1 : 0]}</span>`,
)
.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}<em>${o.simulated ? 'simulated position · ' : ''}${sensors.state.hasOrientation ? 'compass-aligned' : 'drag to look'}</em>`;
}
}
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('<div class="cap-flash"></div>');
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'
? `<video src="${url}" muted playsinline></video>`
: `<img src="${url}" alt="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 = `<div class="seal-card glass-strong">
<div class="seal-media">${kind === 'video' ? `<video src="${url}" muted playsinline autoplay loop></video>` : `<img src="${url}" alt="Captured frame"/>`}${test ? '<span class="badge-example">Test frame</span>' : ''}</div>
<div class="seal-glyph"><svg viewBox="0 0 100 100" aria-hidden="true"><path class="seal-ring" d="${SEAL_D}"/><path class="seal-check" d="M33 51 l11 11 l23 -24"/></svg></div>
<div class="seal-state">Sealing…</div>
<div class="seal-hash mono" aria-label="Bundle hash"></div>
</div>`;
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) => `<span>${g}</span>`)
.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 =
`<b>Sealed</b> · 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',
`<div class="seal-spark">${mags.length > 2 ? `<svg viewBox="0 0 240 34" preserveAspectRatio="none"><path d="${sparkPath(mags, 240, 34, 2)}"/></svg>` : '<div class="seal-nospark">No motion sensors on this device</div>'}<span>IMU ${imu.length} samples${dur ? ` · ${dur.toFixed(1)} s` : ''}</span></div>
<dl class="seal-facts">
<dt>Signed</dt><dd>ECDSA P-256 · key <span class="mono">${esc(bundle.keyId)}</span></dd>
<dt>Chain</dt><dd>#${bundle.seq} ← ${bundle.prev ? `<span class="mono">${esc(bundle.prev.slice(0, 12))}…</span>` : 'genesis'}</dd>
<dt>Where</dt><dd>${f ? `<span class="mono">${f.lat.toFixed(5)}, ${f.lon.toFixed(5)}</span> ±${Math.round(f.acc)} m` : 'no GPS fix'}</dd>
<dt>Heading</dt><dd>${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}</dd>
<dt>Media</dt><dd>${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB</dd>
</dl>
<div class="seal-actions"><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Done</button></div>`,
);
}
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,
};
}
+308
View File
@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** 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(
'<div id="toast" class="toast glass-strong" role="status" aria-live="polite"></div>',
);
document.body.appendChild(el);
}
el.innerHTML = `${iconHtml}<span>${esc(text)}</span>`;
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,
};
}
+350
View File
@@ -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 = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${FAMILIES.map((f, i) => `<i style="--c:${f.color};--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<h1 class="large-title">Now</h1>
<div class="title-sub"><span class="live-dot"></span><span class="feed-count num">Loading the planet…</span></div>
<div class="feed-tabs" role="tablist" aria-label="Feed sections" data-hscroll>
${SECTIONS.map((s, i) => `<button role="tab" type="button" data-sec="${s.id}" aria-selected="${i === 0}">${s.label}</button>`).join('')}
<span class="feed-tabs-ink" aria-hidden="true"></span>
</div>
<div class="feed-note" hidden></div>
<div class="masonry" role="feed" aria-busy="true"><div class="mcol"></div><div class="mcol"></div></div>
<p class="feed-end" hidden>You’re caught up with the planet.</p>
</div>`;
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) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${ASPECTS[(i + ci * 2) % ASPECTS.length]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('');
});
}
function cardHtml(s) {
const m = s.metrics?.[0];
const ar = aspectFor(s);
const fam = FAM_NAME[s.family] || s.family;
return `<article class="card ${s.live ? 'is-live' : isReconstruction(s) ? 'is-recon' : s.stale || (!isAlwaysOn(s) && s.section !== 'always') ? 'is-stale' : 'is-evergreen'}" data-id="${esc(s.id)}" data-family="${esc(s.family)}" aria-labelledby="ct-${esc(s.id)}">
<button class="card-hit" type="button" aria-label="Open ${esc(s.title)}, ${esc(s.place || '')}"></button>
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img src="${esc(s.thumbnail || '')}" alt="" loading="lazy" decoding="async" fetchpriority="low" />
<div class="card-badges">
<span class="fam-tag glass-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)}</span>
${s.live ? '<span class="live-pill"><span class="live-dot"></span>LIVE</span>' : isReconstruction(s) ? `<span class="live-pill recon">${icon('threads')}4D</span>` : ''}
</div>
${m ? `<div class="card-metric"><b class="num">${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}</b>${m.unit ? `<small>${esc(m.unit)}</small>` : ''}<span>${esc(m.label)}</span></div>` : ''}
</div>
<div class="card-body">
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place">${icon('pin')}<span>${esc(s.place || '')}</span></div>
<div class="card-foot"><span class="src">${esc(shortSource(s.source))}</span><span class="ago num" data-ts="${esc(s.updatedAt || '')}">${esc(ago(s.updatedAt))}</span></div>
</div>
</article>`;
}
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 = `<div class="near-prompt glass">${icon('location')}<div><b>See what’s around you</b><span>Sorted from San Francisco until you share your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
} 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();
},
};
}
+549
View File
@@ -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(`<div class="gc-top">
<div class="gc-readout glass-strong liquid"><span class="num gc-alt">—</span><span class="mono gc-ll">—</span></div>
<div class="gc-buttons">
<button class="icon-btn glass-strong liquid" type="button" data-act="lens" aria-label="Visual lens" aria-haspopup="true">${icon('lens')}</button>
<button class="icon-btn glass-strong liquid gc-compass" type="button" data-act="home" aria-label="Reset view to the whole planet"><span class="compass-n">${icon('north')}</span></button>
<button class="icon-btn glass-strong liquid" type="button" data-act="credits" aria-label="Map data attribution">${icon('info')}</button>
</div>
</div>`);
const attribution = h(
'<button class="gc-attr" type="button" data-act="credits" aria-label="Attribution">Esri · Maxar · Earthstar · NASA</button>',
);
const lensPop =
h(`<div class="lens-pop glass-strong" role="menu" aria-label="Visual lens" hidden>
${LENSES.map((l) => `<button role="menuitemradio" type="button" data-lens="${l.id}" aria-checked="false"><span class="lens-sw" style="background:${l.sw}"></span><span>${l.name}</span></button>`).join('')}
</div>`);
const dock =
h(`<div class="lens-dock" role="toolbar" aria-label="Lens families" data-hscroll>
${FAMILIES.map((f) => `<button class="chip fam-chip" type="button" data-family="${f.id}" aria-haspopup="dialog" aria-label="${f.name} layers. Long-press to solo">${icon(FAMILY_ICON[f.id])}<span>${f.name}</span><b class="num fam-count"></b></button>`).join('')}
</div>`);
const pick = h(
`<div class="pick-card glass-strong" role="dialog" aria-label="Selected contact" hidden></div>`,
);
const bottom = h('<div class="gc-bottom"></div>');
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 `<button class="tile ${l.on ? 'on' : ''}" type="button" role="switch" aria-checked="${!!l.on}" data-layer="${esc(l.id)}" data-status="${esc(l.status)}">
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
<span class="tile-name">${esc(l.name)}</span>
<span class="tile-meta"><span class="tile-status"><i></i>${esc(STATUS_LABEL[l.status] || l.status)}</span><b class="num">${l.count != null ? esc(fmtNum(l.count)) : ''}</b></span>
</button>`;
}
function renderGrid(fam) {
const st = familyState()[fam];
const f = FAMILIES.find((x) => x.id === fam);
grid.sheet.dataset.family = fam;
grid.head.innerHTML = `<div class="grid-head"><span class="grid-ic">${icon(FAMILY_ICON[fam])}</span>
<div><h2 class="sheet-title">${f.name}</h2><div class="sheet-sub num">${st.on} of ${st.total} on · ${fmtNum(st.count)} contacts</div></div>
<button class="switch" type="button" role="switch" aria-checked="${st.on > 0}" aria-label="All ${f.name} layers" data-fam-toggle></button></div>`;
grid.body.innerHTML = `<div class="tiles">${st.layers.map(tileHtml).join('')}</div>
<p class="footnote" style="padding:14px 2px 0">Long-press ${f.name} in the dock to solo it. Counts are live from each feed; dotted means stale.</p>`;
}
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 = `<h2 class="sheet-title">Data &amp; attribution</h2><div class="sheet-sub">Imagery, feeds and the people who made them</div>`;
credits.body.innerHTML = `<div class="credit-list">${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 `<div class="credit"><b>${esc(head)}</b>${rest.filter(Boolean).length ? `<span>${esc(rest.filter(Boolean).join(' '))}</span>` : ''}${c.url ? `<a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(new URL(c.url, location.href).hostname)}</a>` : ''}</div>`;
})
.join('')}</div>`;
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 = `<div class="pick-top"><span class="pick-ic">${icon(KIND_ICON[p.kind] || 'scope')}</span>
<div class="pick-titles"><div class="pick-title">${esc(p.title || p.id)}</div><div class="pick-sub">${esc(p.subtitle || p.kind)}</div></div>
<button class="icon-btn pick-close" type="button" aria-label="Dismiss">${icon('close')}</button></div>
${
meta.length
? `<div class="pick-meta">${meta
.slice(0, 6)
.map(
([k, v]) =>
`<div><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`,
)
.join('')}</div>`
: ''
}
<div class="pick-src">${esc(p.meta?.source || '')}${Number.isFinite(p.lat) ? ` · <span class="mono">${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}</span>` : ''}</div>
<div class="pick-actions">
<button class="pill-btn tinted" type="button" data-pick="follow" aria-pressed="${followed}">${icon(followed ? 'starFill' : 'star')}${followed ? 'Following' : 'Follow'}</button>
<button class="pill-btn plain" type="button" data-pick="ride">${icon('ride')}Ride</button>
<button class="pill-btn plain" type="button" data-pick="eye">${icon('video')}Eye</button>
<button class="pill-btn plain" type="button" data-pick="thread">${icon('threads')}Thread</button>
</div>`;
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 = `<header class="navbar"><span class="nav-title">Globe</span></header>
<h1 class="large-title">Globe</h1>
<div class="title-sub">${Object.values(st).reduce((a, s) => a + s.on, 0)} lenses on · drag the planet, pinch to descend</div>
<div class="section-h">Visual lens</div>
<div class="lens-row" data-hscroll>${LENSES.map((l) => `<button type="button" class="lens-card ${l.id === active ? 'on' : ''}" data-lens2="${l.id}"><span class="lens-sw" style="background:${l.sw}"></span>${l.name}</button>`).join('')}</div>
${FAMILIES.map((f) => {
const s = st[f.id];
return `<div class="section-h" data-family="${f.id}"><span class="lead-fam">${icon(FAMILY_ICON[f.id])}${f.name}</span><small class="num">${s.on}/${s.total} · ${fmtNum(s.count)}</small></div>
<div class="tiles lead-tiles" data-family="${f.id}">${s.layers.map(tileHtml).join('')}</div>`;
}).join('')}
<p class="footnote"><button class="link-btn" type="button" data-act2="credits">Data &amp; attribution</button></p>`;
}
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),
};
}
+171
View File
@@ -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');
}
File diff suppressed because it is too large. Load diff
+122
View File
@@ -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: `<circle cx="12" cy="12" r="2.3" ${F}/><path d="M15.4 8.6a4.8 4.8 0 0 1 0 6.8M8.6 8.6a4.8 4.8 0 0 0 0 6.8M18.3 5.7a8.9 8.9 0 0 1 0 12.6M5.7 5.7a8.9 8.9 0 0 0 0 12.6"/>`,
globe: `<circle class="f" cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="9.2"/><ellipse cx="12" cy="12" rx="3.9" ry="9.2"/><path d="M2.8 12h18.4M4.3 7.2c2.2 1 4.8 1.5 7.7 1.5s5.5-.5 7.7-1.5M4.3 16.8c2.2-1 4.8-1.5 7.7-1.5s5.5.5 7.7 1.5"/>`,
viewfinder: `<path d="M3.8 8.6V6.6a2.8 2.8 0 0 1 2.8-2.8h2M15.4 3.8h2a2.8 2.8 0 0 1 2.8 2.8v2M20.2 15.4v2a2.8 2.8 0 0 1-2.8 2.8h-2M8.6 20.2h-2a2.8 2.8 0 0 1-2.8-2.8v-2"/><circle class="f" cx="12" cy="12" r="3.3"/><circle cx="12" cy="12" r="3.3"/>`,
threads: `<path d="M7.4 15.4 10.9 8M13.4 7.7l3.7 5.6M8.5 17.2h6.9" stroke-dasharray="0.2 2.6"/><circle class="f" cx="12" cy="5.9" r="2.3"/><circle cx="12" cy="5.9" r="2.3"/><circle class="f" cx="6.2" cy="17.4" r="2.3"/><circle cx="6.2" cy="17.4" r="2.3"/><circle class="f" cx="18.2" cy="15.6" r="2.3"/><circle cx="18.2" cy="15.6" r="2.3"/>`,
you: `<circle class="f" cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3.2"/><path d="M6.1 18.4c1.3-2.2 3.4-3.3 5.9-3.3s4.6 1.1 5.9 3.3"/>`,
search: `<circle cx="10.6" cy="10.6" r="6.4"/><path d="m15.4 15.4 4.8 4.8"/>`,
chevronLeft: `<path d="M14.6 4.8 7.4 12l7.2 7.2" stroke-width="2.2"/>`,
chevronRight: `<path d="m9.4 4.8 7.2 7.2-7.2 7.2" stroke-width="2"/>`,
chevronDown: `<path d="m5.8 9.2 6.2 6.2 6.2-6.2" stroke-width="2.1"/>`,
chevronUp: `<path d="m5.8 14.8 6.2-6.2 6.2 6.2" stroke-width="2.1"/>`,
close: `<path d="m6.6 6.6 10.8 10.8M17.4 6.6 6.6 17.4" stroke-width="2.1"/>`,
info: `<circle cx="12" cy="12" r="9.2"/><path d="M12 10.9v5.8"/><circle cx="12" cy="7.6" r="1.15" ${F}/>`,
north: `<path d="M12 3.6 16.8 18 12 15.3 7.2 18Z" ${F}/>`,
home: `<path d="M3.8 11.2 12 4.2l8.2 7"/><path d="M6.3 9.4v9.4c0 .6.4 1 1 1h3.2v-5.1h3v5.1h3.2c.6 0 1-.4 1-1V9.4"/>`,
layers: `<path class="f" d="m12 3.8 8.6 4.6-8.6 4.6-8.6-4.6Z"/><path d="m12 3.8 8.6 4.6-8.6 4.6-8.6-4.6Z"/><path d="m3.4 12.4 8.6 4.6 8.6-4.6M3.4 16.4l8.6 4.6 8.6-4.6"/>`,
sky: `<path d="M12 2.6c.9 0 1.5.8 1.5 1.7v5.1l7.4 4.6v1.9l-7.4-2.3v4.9l2 1.5v1.5L12 20.6l-3.5.9V20l2-1.5v-4.9l-7.4 2.3V14l7.4-4.6V4.3c0-.9.6-1.7 1.5-1.7Z" ${F}/>`,
sea: `<path d="M2.8 15.3h18.4l-2.4 4.2a1.6 1.6 0 0 1-1.4.8H6.6a1.6 1.6 0 0 1-1.4-.8Z" ${F}/><path d="M6.4 15.3v-4.4h11.2v4.4M9.4 10.9V7.6h5.2v3.3M12 7.6V4"/>`,
orbit: `<path d="m12 8.9 3.1 3.1-3.1 3.1L8.9 12Z" ${F}/><path d="M5.4 9.6 9.6 5.4 7 2.8 2.8 7ZM18.6 14.4l-4.2 4.2 2.6 2.6 4.2-4.2ZM10.4 10.4 7.6 7.6M13.6 13.6l2.8 2.8M15.2 5.6a3.6 3.6 0 0 1 3.2 3.2"/>`,
hazards: `<path d="M10.3 4.4a2 2 0 0 1 3.4 0l7.3 12.7a2 2 0 0 1-1.7 3H4.7a2 2 0 0 1-1.7-3Z"/><path d="M12 9.4v4.4" stroke-width="2"/><circle cx="12" cy="16.9" r="1.15" ${F}/>`,
flame: `<path d="M12 21c-3.9 0-6.5-2.6-6.5-6.2 0-2.9 1.9-4.6 3-6.3.8 1.5 1.8 2.3 2.6 2.4-.3-2.9.6-5.6 3.2-7.9.2 3 1.4 4.6 2.8 6.2 1.3 1.5 2.4 3.1 2.4 5.6 0 3.6-2.6 6.2-6.5 6.2Z"/>`,
eyes: `<path d="M2.4 12c2.3-4.4 5.7-6.6 9.6-6.6s7.3 2.2 9.6 6.6c-2.3 4.4-5.7 6.6-9.6 6.6S4.7 16.4 2.4 12Z"/><circle cx="12" cy="12" r="3.3"/><circle cx="12" cy="12" r="1.3" ${F}/>`,
ground: `<path d="M3.8 16.6v-4c0-.6.2-1.1.5-1.6L6 7.7c.4-.9 1.2-1.4 2.1-1.4h7.8c.9 0 1.7.5 2.1 1.4l1.7 3.3c.3.5.5 1 .5 1.6v4ZM6 16.6v1.8c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1.8M15 16.6v1.8c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1.8M5.4 10.9h13.2"/><circle cx="7.6" cy="13.7" r="1" ${F}/><circle cx="16.4" cy="13.7" r="1" ${F}/>`,
weather: `<path d="M7.4 19.2h9.9a3.6 3.6 0 0 0 .3-7.2 5.2 5.2 0 0 0-10 1.3 3 3 0 0 0-.2 5.9Z"/><path d="M5.6 11.3a3.3 3.3 0 0 1 5.6-4M8.4 2.9v1.4M3 8.4h1.4M4.6 4.6l1 1"/>`,
infra: `<path d="M13.2 2.8 5.6 13.3h5.8l-1 7.9 7.6-10.5h-5.8Z"/>`,
star: `<path class="f" d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z"/><path d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z"/>`,
starFill: `<path d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z" fill="currentColor"/>`,
ride: `<circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="2.4"/><path d="M3.2 10.6c2.8-.9 5.7-1.3 8.8-1.3s6 .4 8.8 1.3M10.2 14 7.4 20M13.8 14l2.8 6"/>`,
video: `<rect x="2.4" y="6.8" width="13.4" height="10.4" rx="2.4"/><path d="m15.8 10.6 5.4-3.1v9l-5.4-3.1"/>`,
share: `<path d="M12 3.4v11.2M8.2 7.2 12 3.4l3.8 3.8M8.6 10H7a2 2 0 0 0-2 2v6.6c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V12a2 2 0 0 0-2-2h-1.6"/>`,
seal: `<path class="f" d="${SEAL}"/><path d="${SEAL}"/><path d="m8.3 12.2 2.5 2.5 4.9-5" stroke-width="2"/>`,
sealFill: `<path d="${SEAL}" fill="currentColor"/><path d="m8.3 12.2 2.5 2.5 4.9-5" stroke="var(--seal-ink, #2a1a00)" stroke-width="2.1"/>`,
play: `<path d="M8 5.2v13.6l10.6-6.8Z" fill="currentColor"/>`,
pause: `<path d="M8 5.5v13M16 5.5v13" stroke-width="3"/>`,
sparkles: `<path d="M11 3.2c.6 3.7 2.4 5.5 6.1 6.1-3.7.6-5.5 2.4-6.1 6.1-.6-3.7-2.4-5.5-6.1-6.1 3.7-.6 5.5-2.4 6.1-6.1Z" fill="currentColor"/><path d="M18 14.4c.3 1.8 1.1 2.6 2.9 2.9-1.8.3-2.6 1.1-2.9 2.9-.3-1.8-1.1-2.6-2.9-2.9 1.8-.3 2.6-1.1 2.9-2.9Z" fill="currentColor"/>`,
agree: `<circle cx="12" cy="12" r="9.2"/><path d="m8 12.3 2.8 2.8 5.2-5.4"/>`,
dispute: `<circle cx="12" cy="12" r="9.2"/><path d="M8.2 12h7.6"/>`,
sliders: `<path d="M4 6.5h9M17 6.5h3M4 12h3M11 12h9M4 17.5h11M19 17.5h1"/><circle cx="15" cy="6.5" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="17.5" r="2"/>`,
refresh: `<path d="M19.4 12.6a7.5 7.5 0 1 1-2.1-6.1"/><path d="M19.6 4.4v4.3h-4.3"/>`,
lock: `<rect x="4.6" y="10.4" width="14.8" height="10.2" rx="2.2"/><path d="M8 10.4V7.6a4 4 0 0 1 8 0v2.8"/>`,
key: `<circle cx="8" cy="15.5" r="4.2"/><path d="m11 12.5 8.6-8.6M16.4 7.1l2.4 2.4M14.2 9.3l1.8 1.8"/>`,
location: `<path d="M20.2 3.8 3.8 11l7.2 2 2 7.2Z" fill="currentColor" stroke-linejoin="round"/>`,
camera: `<path d="M4 8h3l1.5-2.3h7L17 8h3c.8 0 1.5.7 1.5 1.5V18c0 .8-.7 1.5-1.5 1.5H4c-.8 0-1.5-.7-1.5-1.5V9.5C2.5 8.7 3.2 8 4 8Z"/><circle cx="12" cy="13.4" r="3.6"/>`,
cube: `<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/>`,
download: `<path d="M12 3.4v11.2M8.2 10.8 12 14.6l3.8-3.8M5 15.5V18c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2v-2.5"/>`,
lens: `<circle cx="12" cy="8.6" r="5"/><circle cx="8.6" cy="14.6" r="5"/><circle cx="15.4" cy="14.6" r="5"/>`,
checkmark: `<path d="m5 12.6 4.4 4.4L19 7.4" stroke-width="2.2"/>`,
xmark: `<path d="m7 7 10 10M17 7 7 17" stroke-width="2.2"/>`,
minus: `<path d="M6 12h12" stroke-width="2.2"/>`,
plus: `<path d="M12 5.5v13M5.5 12h13" stroke-width="2"/>`,
ellipsis: `<circle cx="6" cy="12" r="1.6" ${F}/><circle cx="12" cy="12" r="1.6" ${F}/><circle cx="18" cy="12" r="1.6" ${F}/>`,
clock: `<circle cx="12" cy="12" r="9.2"/><path d="M12 7v5.3l3.4 2.1"/>`,
pin: `<path d="M12 21s-6.4-5.6-6.4-11a6.4 6.4 0 0 1 12.8 0c0 5.4-6.4 11-6.4 11Z"/><circle cx="12" cy="10" r="2.3"/>`,
sidebar: `<rect x="3" y="4.5" width="18" height="15" rx="3"/><path d="M9.5 4.5v15M5.5 8.5h1.6M5.5 11.5h1.6"/>`,
waveform: `<path d="M3 12h2M6.5 8v8M10 4.5v15M13.5 9v6M17 6.5v11M20.5 10.5v3"/>`,
arrowUpRight: `<path d="M7 17 17 7M8.5 7H17v8.5"/>`,
scope: `<circle cx="12" cy="12" r="7.6"/><path d="M12 2.4v4M12 17.6v4M2.4 12h4M17.6 12h4"/><circle cx="12" cy="12" r="1.2" ${F}/>`,
hand: `<path d="M8.4 12.6V5.8a1.4 1.4 0 0 1 2.8 0v5.4M11.2 10.8V4.4a1.4 1.4 0 0 1 2.8 0v6.4M14 10.8V5.6a1.4 1.4 0 0 1 2.8 0v7.6M8.4 12.6l-1.3-1.7a1.5 1.5 0 0 0-2.3 1.9l3.4 5.3c1.2 1.8 3 2.9 5.2 2.9h.4c2.6 0 4.9-2.1 4.9-4.8V8.5a1.4 1.4 0 0 0-2.8 0"/>`,
};
/**
* 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 `<svg class="i ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" ${a11y}>${body}</svg>`;
}
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]),
);
+114
View File
@@ -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 `<div class="sm"><span>${label}</span><em>—</em></div>`;
const lo = Math.min(...v);
const hi = Math.max(...v);
return `<div class="sm"><span>${label}</span><svg viewBox="0 0 120 26" preserveAspectRatio="none"><path d="${sparkPath(v, 120, 26, 2)}"/></svg><em class="num">${lo.toFixed(1)}–${hi.toFixed(1)}${unit}</em></div>`;
}
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 = `<div class="insp-head">
<div class="insp-thumb">${url ? (isVideo ? `<video src="${url}" muted playsinline></video>` : `<img src="${url}" alt="Capture ${bundle.seq}"/>`) : ''}<span>${icon('sealFill')}</span></div>
<div><h2 class="sheet-title">Capture #${bundle.seq}</h2><div class="sheet-sub">${esc(when.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' }))}${bundle.context?.testFrame ? ' · <span class="badge-example">Test frame</span>' : ''}</div></div>
</div>`;
sheet.body.innerHTML = `<div class="insp-banner running"><span class="spinner"></span>Recomputing hashes and checking the signature…</div>`;
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 = `
<div class="insp-banner ${fails ? 'fail' : warns ? 'warn' : 'pass'}">${icon(fails ? 'xmark' : 'seal')}<div><b>${fails ? `${fails} check${fails > 1 ? 's' : ''} failed` : warns ? `Verified with ${warns} caveat${warns > 1 ? 's' : ''}` : 'All checks passed'}</b><span>Recomputed on this device in ${ms} ms. None of this proves truth; it raises the cost of lying.</span></div></div>
<ol class="checks">${res.checks
.map(
(c) =>
`<li class="check ${c.status}"><span class="ck">${icon(GLYPH[c.status] || 'minus')}</span><div><b>${esc(c.label)}</b><span>${esc(c.detail)}</span></div></li>`,
)
.join('')}</ol>
<div class="block"><div class="block-h">Motion during capture<small>${imu.length} samples</small></div>
<div class="smalls">${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)),
'',
)}</div></div>
${f ? `<div class="block"><div class="block-h">Where it was sealed<small>±${Math.round(f.acc)} m</small></div><div class="insp-map"><img src="${esc(esriThumb(f.lon, f.lat, 0.012, 600, 300))}" alt="" loading="lazy"/><i></i><span class="mono">${f.lat.toFixed(5)}, ${f.lon.toFixed(5)}</span></div></div>` : ''}
<div class="block"><div class="block-h">Bundle</div><dl class="facts mono-facts">
<dt>hash</dt><dd class="mono">${esc(bundle.hash)}</dd>
<dt>media</dt><dd class="mono">${esc(bundle.media.sha256)}</dd>
<dt>prev</dt><dd class="mono">${esc(bundle.prev || 'null (genesis)')}</dd>
<dt>key</dt><dd class="mono">${esc(bundle.keyId)} · P-256</dd>
<dt>sig</dt><dd class="mono">${esc(bundle.signature.slice(0, 48))}…</dd>
<dt>device</dt><dd>${esc(bundle.context?.device || '')} · ${esc(bundle.context?.timezone || '')}</dd></dl></div>
<div class="insp-actions"><button class="pill-btn plain" type="button" data-insp="export">${icon('download')}Export bundle</button><button class="pill-btn plain" type="button" data-insp="reverify">${icon('refresh')}Verify again</button></div>
<p class="footnote" style="padding:12px 0 0">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.</p>`;
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 };
}
+142
View File
@@ -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(`<div class="narr-hud glass-strong" role="region" aria-label="Narrative">
<div class="narr-top"><button class="narr-play" type="button" aria-label="Pause">${icon('pause')}</button>
<div class="narr-titles"><div class="narr-step"></div><div class="narr-name"></div></div>
<button class="icon-btn pick-close" type="button" aria-label="Stop narrative">${icon('close')}</button></div>
<div class="narr-prog"></div>
<div class="narr-cav"></div>
</div>`);
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(() => '<i></i>')
.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;
},
};
}
+139
View File
@@ -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 = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Situations, lenses, places" aria-label="Search" enterkeyhint="search" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
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 = `
<div class="results-h">${q ? 'Situations' : 'Live now'}</div>
${s.map((x) => `<button class="result" type="button" data-sit="${esc(x.id)}"><img class="result-thumb" src="${esc(x.thumbnail)}" alt="" loading="lazy"/><span><div class="result-title">${esc(x.title)}</div><div class="result-sub">${esc(x.place)}</div></span></button>`).join('') || '<p class="result-sub">No situations match.</p>'}
${layers.length ? `<div class="results-h">Lenses</div>${layers.map((l) => `<button class="result" type="button" data-layer="${esc(l.id)}" data-family="${esc(l.family)}"><span class="result-icon">${icon(FAMILY_ICON[l.family] || 'layers')}</span><span><div class="result-title">${esc(l.name)}</div><div class="result-sub">${esc(l.family)} · ${l.on ? 'on' : 'off'}${l.count != null ? ` · ${l.count}` : ''}</div></span></button>`).join('')}` : ''}
<div class="results-h">Places</div>
${[...places, ...geo].map((p, i) => `<button class="result" type="button" data-place="${i}"><span class="result-icon">${icon('pin')}</span><span><div class="result-title">${esc(p.name)}</div><div class="result-sub">${esc(p.sub || '')}</div></span></button>`).join('')}
${q.length >= 3 ? '<p class="result-sub" style="padding:10px 4px">Press search to look up places · © OpenStreetMap contributors</p>' : ''}`;
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);
},
};
}
+292
View File
@@ -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?: Record<string,(H:number)=>number>, 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(`<div class="sheet-layer ${o.modal ? 'is-modal' : ''}" hidden>
${o.modal ? '<div class="sheet-scrim" aria-hidden="true"></div>' : ''}
<section class="sheet ${o.className || ''}" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<button class="sheet-grabber" type="button" aria-label="${o.label}: resize"><span></span></button>
${o.closeButton ? `<button class="sheet-close icon-btn" type="button" aria-label="Close">${icon('close')}</button>` : ''}
<div class="sheet-head"></div>
<div class="sheet-body"></div>
</section>
</div>`);
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),
};
}
+153
View File
@@ -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 = `
<div class="tb-full">
<button class="tb-accessory glass" type="button" hidden aria-label="Followed situation"></button>
<div class="tb-tabs glass" role="tablist" aria-label="Sections">
<span class="tb-lens" aria-hidden="true"></span>
${TABS.map(
(
t,
) => `<button class="tb-tab" role="tab" type="button" id="tab-${t.id}" data-tab="${t.id}" aria-selected="false" aria-controls="view-${t.id}">
${icon(t.icon)}<span class="tb-label">${t.label}</span></button>`,
).join('')}
</div>
<button class="tb-search glass" type="button" aria-label="Search">${icon('search')}</button>
</div>
<div class="tb-mini" aria-hidden="true">
<button class="tb-mini-tab glass" type="button" tabindex="-1" aria-label="Show tabs"></button>
<button class="tb-mini-acc glass liquid" type="button" tabindex="-1" hidden></button>
<button class="tb-search glass" type="button" tabindex="-1" aria-label="Search">${icon('search')}</button>
</div>`;
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 `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(FAMILY_ICON[s.family] || 'now')}</span>
<span class="acc-text"><span class="acc-title">${esc(s.title)}</span>
<span class="acc-meta">${esc(s.place || '')} · ${esc(ago(s.updatedAt))}</span></span>
${m ? `<span class="acc-metric num" data-family="${esc(s.family)}">${esc(fmtNum(m.value))}${m.unit ? ` <small>${esc(m.unit)}</small>` : ''}</span>` : ''}`;
}
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;
},
};
}
+769
View File
@@ -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 = `
<div class="th-list">
<header class="navbar"><span class="nav-title">Threads</span></header>
<h1 class="large-title">Threads</h1>
<div class="title-sub">Reconstructions built in the open: observations, hypotheses, a ledger</div>
<div class="th-cards"></div>
</div>
<div class="th-detail" hidden></div>`;
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
? `<button class="th-card" type="button" data-thread="${esc(bhote.id)}">
<div class="th-ba"><img src="${bhote.before}" alt="" loading="lazy" decoding="async"/><img src="${bhote.after}" alt="" loading="lazy" decoding="async"/><span class="th-ba-l">2021</span><span class="th-ba-r">2026</span></div>
<div class="th-card-body">
<div class="th-kicker" data-family="hazards"><span class="fam-tag">${icon('hazards')}Flood</span><span>Real event pack</span></div>
<h3>${esc(bhote.title)}</h3>
<div class="th-place">${esc(bhote.place)} · ${esc(new Date(bhote.observedDate).toLocaleDateString(undefined, { dateStyle: 'medium' }))}</div>
<div class="th-stats num"><span><b>${bhote.observations.length}</b> observations</span><span><b>${bhote.hypotheses.length + (local.proposals?.length || 0)}</b> hypotheses</span><span><b>${bhote.steps.length}</b> narrative stops</span></div>
<div class="th-lic">${esc(bhote.license)}</div>
</div></button>`
: '<p class="footnote">The bundled event pack could not be loaded.</p>'
}
${drafts
.map(
(
d,
) => `<button class="th-card th-small" type="button" data-draft="${esc(d.id)}" data-family="${esc(d.family || 'infra')}">
<span class="th-small-ic">${icon('threads')}</span><div class="th-card-body"><div class="th-kicker"><span>Your draft</span><span>${esc(ago(d.createdAt))}</span></div><h3>${esc(d.title)}</h3><div class="th-place">${esc(d.place || '')}</div></div>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}
<div class="section-h">More reconstructions <small><span class="badge-example">Example</span></small></div>
${[
{
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) =>
`<button class="th-card th-small example" type="button" data-example data-family="${x.f}"><span class="th-small-ic">${icon(x.i)}</span><div class="th-card-body"><div class="th-kicker"><span class="badge-example">Example</span><span>concept</span></div><h3>${esc(x.t)}</h3><div class="th-place">${esc(x.p)}</div></div>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}
<p class="footnote">Example threads are placeholders showing the genres the format supports; they contain no real records.</p>`;
}
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 `<article class="hyp ${hy.simulated ? 'proposed' : ''}" data-h="${esc(hy.id)}">
<div class="hyp-top"><span class="hyp-id">${esc(hy.id)}</span><span class="hyp-author ${ai ? 'ai' : ''}">${ai ? icon('sparkles') : ''}${esc(hy.author.handle)}</span>
${hy.example ? '<span class="badge-example">Example</span>' : ''}${hy.simulated ? '<span class="badge-example badge-sim">Proposed · local simulation</span>' : ''}<span class="hyp-ago">${esc(ago(hy.at))}</span></div>
<p class="hyp-text">${esc(hy.text)}</p>
<div class="hyp-cites">${hy.cites.map((c) => `<button type="button" class="cite" data-cite="${esc(c)}">${esc(c)}</button>`).join('')}</div>
${hy.simulated ? `<dl class="hyp-prov"><dt>prompt</dt><dd>${esc(hy.prompt)}</dd><dt>model</dt><dd class="mono">${esc(hy.model)}</dd><dt>inputs</dt><dd class="mono">${hy.inputs.map((i) => `${esc(i.id)}:${esc(i.recordHash.slice(0, 10))}`).join(' ')}</dd></dl>` : ''}
<div class="hyp-foot">
<div class="conf" title="Confidence"><span>Confidence</span><i style="--v:${hy.confidence ?? 0}"></i><b class="num">${hy.confidence == null ? 'unscored' : `${Math.round(hy.confidence * 100)}%`}</b></div>
<div class="votes"><div class="vote-bar"><i style="width:${(agree / total) * 100}%"></i></div>
<button type="button" class="vote ${mine === 'agree' ? 'on' : ''}" data-vote="agree" aria-label="Agree">${icon('agree')}<span class="num">${agree}</span></button>
<button type="button" class="vote dis ${mine === 'dispute' ? 'on' : ''}" data-vote="dispute" aria-label="Dispute">${icon('dispute')}<span class="num">${dispute}</span></button></div>
</div></article>`;
}
function obsHtml(o) {
return `<li class="obs" data-o="${esc(o.id)}">
<button class="obs-row" type="button" aria-expanded="false">
<span class="obs-id">${esc(o.id)}</span>
<span class="obs-main"><b>${esc(o.title)}</b><span>${esc(o.phase)} · ${esc(o.source)}</span></span>
<span class="obs-hash mono">${icon('lock')}${esc(o.hash.slice(0, 8))}</span>
</button>
<div class="obs-more" hidden>
${o.summary ? `<p>${esc(o.summary)}</p>` : ''}
<dl class="facts"><dt>Where</dt><dd class="mono">${esc(fmtCoord(o.lat, o.lon, 5))}</dd>
<dt>Location</dt><dd>${esc(o.locConf || '—')}</dd><dt>Time</dt><dd>${esc(o.time || '—')} · ${esc(o.timeConf || '')}</dd>
<dt>Via</dt><dd>${esc(o.via || '—')}</dd>${o.rights ? `<dt>Rights</dt><dd>${esc(o.rights)}</dd>` : ''}
<dt>sha256</dt><dd class="mono">${esc(o.hash)}</dd></dl>
<div class="obs-actions">${o.url ? `<a class="pill-btn plain" href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">${icon('arrowUpRight')}Original</a>` : ''}<button class="pill-btn tinted" type="button" data-globe="${esc(o.id)}">${icon('globe')}Show on globe</button></div>
</div></li>`;
}
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 = `
<header class="navbar"><div class="nav-leading"><button class="icon-btn back-btn" type="button" aria-label="Back to threads">${icon('chevronLeft')}<span>Threads</span></button></div><span class="nav-title">${esc(thread.title)}</span></header>
<div class="th-hero">
${thread.before ? `<div class="th-ba big"><figure><img src="${thread.before}" alt="2021 reference imagery" decoding="async"/><figcaption>2021 · reference</figcaption></figure><figure><img src="${thread.after}" alt="2026 post-event imagery" decoding="async"/><figcaption>27 Aug 2026 · post-event</figcaption></figure></div>` : ''}
<h1 class="th-title">${esc(thread.title)}</h1>
<div class="title-sub" style="padding:0">${esc(thread.place || '')}${thread.real ? ' · real event pack' : ' · your draft'}</div>
${thread.label ? `<div class="caveat"><b>${esc(thread.label)}</b>${esc(thread.caveat)}</div>` : ''}
</div>
<nav class="strata-nav" aria-label="Strata"><div class="segmented">
<button type="button" data-jump="obs" aria-pressed="true">Observations</button><button type="button" data-jump="hyp" aria-pressed="false">Hypotheses</button><button type="button" data-jump="nar" aria-pressed="false">Narrative</button><button type="button" data-jump="led" aria-pressed="false">Ledger</button>
<span class="seg-thumb"></span></div></nav>
<section class="stratum" id="st-obs"><div class="stratum-h"><span class="st-n">1</span><div><h2>Observations</h2><p>Immutable. Each has a source, a time and a hash. Nobody edits an observation.</p></div></div>
<ol class="obs-list">${thread.observations.map(obsHtml).join('')}</ol></section>
<section class="stratum" id="st-hyp"><div class="stratum-h"><span class="st-n">2</span><div><h2>Hypotheses</h2><p>Claims that connect observations. Cite, dispute or supersede — never edit.</p></div></div>
${thread.hypotheses.some((x) => x.example) ? '<div class="example-note"><span class="badge-example">Example content</span> These hypotheses, authors and vote counts are fictional, written to show the format. The observations above are real.</div>' : ''}
<div class="hyps">${[...thread.hypotheses, ...proposals].map((x) => hypHtml(x, votes)).join('') || '<p class="footnote" style="padding:0">No hypotheses yet.</p>'}</div>
<form class="composer" autocomplete="off">
<label for="ai-prompt">${icon('sparkles')}Ask AI to refine</label>
<textarea id="ai-prompt" rows="2" placeholder="Align the witness clip to the corridor using the bridge as a landmark" data-no-drag></textarea>
<div class="composer-row"><span>Creates a <b>proposed</b> hypothesis with its prompt, model and inputs in the ledger. Local simulation — no network model is called.</span><button class="pill-btn primary" type="submit">Propose</button></div>
</form></section>
<section class="stratum" id="st-nar"><div class="stratum-h"><span class="st-n">3</span><div><h2>Narrative</h2><p>A camera path through space and time that cites the strata below it.</p></div></div>
<div class="narr-card">
<button class="narr-play" type="button" aria-label="Play narrative">${icon('play')}</button>
<div><b>${thread.steps.length} stops${thread.elapsedSeconds ? ` · ${Math.round(thread.elapsedSeconds / 60)} min schematic` : ''}</b><span>Flies the globe down the valley, scrubbing the time ribbon.</span></div>
</div>
<ol class="narr-steps">${thread.steps.map((s, i) => `<li><span class="num">${String(i + 1).padStart(2, '0')}</span><b>${esc(s.title)}</b><em>${esc(s.phase || '')}</em></li>`).join('')}</ol></section>
<section class="stratum" id="st-led"><div class="stratum-h"><span class="st-n">#</span><div><h2>Ledger</h2><p>Every change, hash-chained with SHA-256 on this device. Anyone can export and verify it.</p></div></div>
<div class="ledger-actions"><button class="pill-btn plain" type="button" data-led="verify">${icon('checkmark')}Verify chain</button><button class="pill-btn plain" type="button" data-led="export">${icon('download')}Export ledger</button><span class="led-status num"></span></div>
<ol class="ledger">${L.map(
(
e,
) => `<li class="led ${e.type.split('.')[0]}"><button type="button" class="led-row" aria-expanded="false"><span class="led-seq num">${e.seq}</span><span class="led-main"><b>${esc(e.type)}</b><span>${esc(e.actor)} · ${esc(e.at ? String(e.at).slice(0, 10) : '')}</span></span><span class="led-hash mono">${esc(e.hash.slice(0, 10))}</span></button>
<pre class="led-json mono" hidden>${esc(JSON.stringify({ ...e, payload: e.payload }, null, 2))}</pre></li>`,
).join('')}</ol>
<p class="footnote" style="padding:8px 0 0">Head <span class="mono">${esc(L.at(-1)?.hash.slice(0, 24) || '')}…</span> · ${L.length} entries</p>
</section>`;
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())),
};
}
+412
View File
@@ -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(`<div class="ribbon glass-strong liquid" role="group" aria-label="Time">
<button class="rb-live is-live" type="button" aria-label="Return to live"><span class="live-dot"></span><span>LIVE</span></button>
<div class="rb-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head" aria-hidden="true"></div>
</div>
<div class="rb-read" aria-live="off"><b class="num">Now</b><span class="num"></span></div>
</div>`);
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 };
},
};
}
+553
View File
@@ -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(
`<button class="world-back icon-btn glass-strong liquid" type="button" aria-label="Back to feed">${icon('chevronLeft')}</button>`,
);
$('#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 `<div class="metric ${String(v).length > 5 ? 'long' : ''}"><div class="metric-v">${esc(v)}${m.unit ? `<small>${esc(m.unit)}</small>` : ''}</div><div class="metric-l">${esc(m.label)}</div></div>`;
}
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 `<div class="block"><div class="block-h">Trend<small>${esc(m?.label || '')} · last ${s.series.length} samples</small></div>
<div class="spark-row"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="spark-grad" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0"/></linearGradient></defs>
<path class="spark-area" d="${d}L${W - 3} ${H}L3 ${H}Z"/><path class="spark-line" d="${d}"/><circle class="spark-end" cx="${last[0]}" cy="${last[1]}" r="3"/></svg>
<div class="spark-label"><b>${esc(m ? fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0) : '')}</b>${esc(m?.unit || '')} now</div></div></div>`;
}
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,
) => `<a class="ev" href="${esc(esriThumb(s.lon, s.lat, f.span, 900, 1100))}" target="_blank" rel="noopener">
<img class="ev-img" src="${esc(esriThumb(s.lon, s.lat, f.span, 232, 280))}" alt="" loading="lazy" decoding="async"/>
<span class="ev-seq">${i + 1}</span><div class="ev-t">${f.t} context</div><div class="ev-s">Esri World Imagery · archival</div></a>`,
)
.join('');
}
async function packEvidence() {
const pack = await loadEventPack();
const pre = `<a class="ev" href="/events/bhote-koshi-2026/pre.webp" target="_blank"><img class="ev-img" src="/events/bhote-koshi-2026/pre.webp" alt="" loading="lazy" decoding="async"/><span class="ev-seq">A</span><div class="ev-t">Before · 16 Oct 2021</div><div class="ev-s">Vantor WorldView-2</div></a>
<a class="ev" href="/events/bhote-koshi-2026/post.webp" target="_blank"><img class="ev-img" src="/events/bhote-koshi-2026/post.webp" alt="" loading="lazy" decoding="async"/><span class="ev-seq">B</span><div class="ev-t">After · 27 Aug 2026</div><div class="ev-s">Vantor WorldView-3</div></a>`;
return (
pre +
pack.evidenceSpine
.map(
(
e,
) => `<a class="ev" href="${esc(e.media?.sourceUrl || '#')}" target="_blank" rel="noopener noreferrer">
<img class="ev-img" src="${esc(e.media?.posterUrl || esriThumb(e.lon, e.lat, 0.03, 232, 280))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer"/>
<span class="ev-seq">${e.sequence}</span><div class="ev-t">${esc(e.shortTitle || e.title)}</div><div class="ev-s">${esc(e.media?.platform || 'Source')} · ${esc(e.confidence?.time || '')}</div></a>`,
)
.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
? `<span class="live-tag"><span class="live-dot"></span>Live</span>`
: recon
? '<span>Reconstruction</span>'
: '<span>Stale</span>';
sheet.head.innerHTML = `
<div class="brief-top"><span class="fam-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(FAM_NAME[s.family] || s.family)}</span>${status}<span>·</span><span class="num">${esc(ago(s.updatedAt))}</span></div>
<div class="brief-title-row"><h2 class="sheet-title">${esc(s.title)}</h2>${m0 ? `<span class="peek-metric num">${esc(typeof m0.value === 'number' ? fmtNum(m0.value, m0.value % 1 ? 1 : 0) : m0.value)}<small>${esc(m0.unit || '')}</small></span>` : ''}</div>
<div class="sheet-sub">${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}</div>`;
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 `<span class="chip layer-chip" data-family="${esc(fam)}">${icon(FAMILY_ICON[fam] || 'layers')}${esc(l?.name || id)}</span>`;
})
.join('');
sheet.body.innerHTML = `
${metrics.length ? `<div class="metrics">${metrics.map(metricHtml).join('')}</div>` : ''}
<div class="actions" role="group" aria-label="Actions">
<button class="action" type="button" data-act="follow" aria-pressed="${followed}">${icon('star')}<span>${followed ? 'Following' : 'Follow'}</span></button>
<button class="action" type="button" data-act="ride">${icon('ride')}<span>Ride</span></button>
<button class="action" type="button" data-act="eye">${icon('video')}<span>Nearest eye</span></button>
<button class="action" type="button" data-act="thread">${icon('threads')}<span>Thread</span></button>
<button class="action" type="button" data-act="share">${icon('share')}<span>Share</span></button>
</div>
${sparkHtml(s)}
<div class="block"><div class="block-h">Evidence<small>${recon ? 'sourced observations · tap for original' : 'context imagery'}</small></div>
<div class="evidence" data-hscroll>${recon ? '<div class="ev"><div class="ev-img"></div></div>'.repeat(4) : contextEvidence(s)}</div></div>
<div class="block"><div class="block-h">Every number, its source and age</div>
<table class="metric-table"><tbody>${(s.metrics || [])
.map(
(m) =>
`<tr><td>${esc(m.label)}</td><td>${esc(typeof m.value === 'number' ? fmtNum(m.value, m.value % 1 ? 1 : 0) : m.value)} ${esc(m.unit || '')}</td><td>${esc(m.source || s.source || '')} · ${esc(ago(m.updatedAt || s.updatedAt))}</td></tr>`,
)
.join('')}</tbody></table></div>
<div class="block"><div class="block-h">Lenses this situation turns on</div><div class="layer-chips">${layerChips || '<span class="sheet-sub">None</span>'}</div></div>
<div class="block"><div class="block-h">Details</div><dl class="facts">
<dt>Where</dt><dd class="mono">${esc(fmtCoord(s.lat, s.lon, 4))}</dd>
<dt>Window</dt><dd>${esc(time)}</dd>
<dt>Kind</dt><dd>${esc(s.kind || '—')}</dd>
<dt>Source</dt><dd>${esc(s.source || '—')}</dd></dl></div>
${s.caveat ? `<div class="caveat"><b>Caveat</b>${esc(s.caveat)}</div>` : ''}`;
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',
`<div class="caveat"><b>${esc(pack.reconstruction.label)}</b>${esc(pack.reconstruction.caveat)}</div>`,
);
})
.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(
`<div class="hero" aria-hidden="true"><img alt="" decoding="sync"/></div>`,
);
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('<div class="hero-scrim" aria-hidden="true"></div>');
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);
},
};
}
+163
View File
@@ -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 = `
<header class="navbar"><span class="nav-title">You</span></header>
<h1 class="large-title">You</h1>
<div class="you-id"><span class="avatar">${icon('you')}</span><div><b>This device</b>
<span>${key ? `Signing key <span class="mono">${esc(key.keyId)}</span> · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'}</span>
<span>${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${followed.length} followed</span></div></div>
<div class="section-h">Captures <small>tap to verify</small></div>
${
captures.length
? `<div class="captures">${captures
.slice(0, 30)
.map(
(c, i) =>
`<button class="capture-tile" type="button" data-cap="${esc(c.id)}" aria-label="Capture ${c.seq}, ${esc(ago(c.sealedAt))}">${thumbs[i] ? (/video/.test(c.media.type) ? `<video src="${thumbs[i]}" muted playsinline preload="metadata"></video>` : `<img src="${thumbs[i]}" alt="" loading="lazy" decoding="async"/>`) : ''}<span class="ct-n num">#${c.seq}</span><span class="ct-seal">${icon('sealFill')}</span></button>`,
)
.join('')}</div>`
: `<div class="empty">${icon('seal')}<div>No sealed captures yet.</div><div>Open Capture → Witness and tap the shutter.</div></div>`
}
<div class="section-h">Following</div>
${
fol.length || contacts.length
? `<div class="group">${fol
.map(
(s) =>
`<button class="row" type="button" data-sit="${esc(s.id)}" data-family="${esc(s.family)}"><span class="row-icon">${icon(FAMILY_ICON[s.family] || 'now')}</span><span class="row-main"><span class="row-title">${esc(s.title)}</span><span class="row-sub">${esc(s.place)} · ${esc(ago(s.updatedAt))}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join(
'',
)}${contacts.map((c) => `<div class="row"><span class="row-icon" style="--fam:var(--sky)">${icon('scope')}</span><span class="row-main"><span class="row-title mono">${esc(c.slice(8))}</span><span class="row-sub">Contact</span></span></div>`).join('')}</div>`
: '<div class="empty">Follow a situation from its brief and it appears here and in the tab-bar accessory.</div>'
}
<div class="section-h">Holograms <small><span class="badge-example">Concept</span></small></div>
<div class="holo">${icon('cube')}<div><b>Leave something in a place</b>Pin a 3D note to a spot on Earth for people who visit with Look. Concept placeholder in this prototype.</div></div>
<div class="section-h">Settings</div>
<div class="group">
<div class="row"><span class="row-icon" style="--fam:var(--sky)">${icon('globe')}</span><span class="row-main"><span class="row-title">Globe quality</span><span class="row-sub">Applies on next launch</span></span></div>
<div class="seg-row"><div class="segmented" data-set="quality">${['auto', 'high', 'battery'].map((v) => `<button type="button" data-v="${v}" aria-pressed="${q === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--orbit)">${icon('sparkles')}</span><span class="row-main"><span class="row-title">Reduce motion</span><span class="row-sub">Cross-fades instead of flights and morphs</span></span></div>
<div class="seg-row"><div class="segmented" data-set="reduceMotion">${['system', 'on', 'off'].map((v) => `<button type="button" data-v="${v}" aria-pressed="${rm === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--hazards)">${icon('pin')}</span><span class="row-main"><span class="row-title">Alert radius</span><span class="row-sub">Situations inside it ping you</span></span><span class="row-value num" data-radius>${radius.toLocaleString()} km</span></div>
<div class="slider-row"><input class="slider" type="range" min="25" max="2000" step="25" value="${radius}" aria-label="Alert radius in kilometres" style="--p:${((radius - 25) / 1975) * 100}%"/></div>
<div class="row"><span class="row-icon" style="--fam:var(--eyes)">${icon('hand')}</span><span class="row-main"><span class="row-title">Haptics</span><span class="row-sub">Ribbon detents and seals only</span></span><button class="switch" type="button" role="switch" aria-checked="${store.get('haptics')}" aria-label="Haptics" data-switch="haptics"></button></div>
</div>
<div class="section-h">About</div>
<div class="group">
<button class="row" type="button" data-act="credits"><span class="row-icon" style="--fam:var(--infra)">${icon('info')}</span><span class="row-main"><span class="row-title">Data &amp; attribution</span><span class="row-sub">Esri, NASA, Vantor, GeoPera, OpenStreetMap</span></span>${icon('chevronRight', { cls: 'chev' })}</button>
<div class="row"><span class="row-icon" style="--fam:var(--seal)">${icon('lock')}</span><span class="row-main"><span class="row-title">Provenance</span><span class="row-sub">Prototype uses WebCrypto ECDSA P-256 with a non-extractable key in IndexedDB. Native builds use the Secure Enclave and C2PA.</span></span></div>
</div>
<p class="footnote">God’s Eye mobile prototype. The window through which you see the world, and the eye through which the world sees itself.</p>`;
$$('.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 };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+49
View File
@@ -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 <!--@CSS--> <!--@JS--> <!--@TEX--> 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 = `<script>window.__GE_TEX=${JSON.stringify(tex)};</script>`;
const appScript = `<script>\n(() => {\n'use strict';\n${js}\n})();\n</script>`;
let out = tpl
.replace('<!--@CSS-->', () => `<style>\n${css}\n</style>`)
.replace('<!--@TEX-->', () => texScript)
.replace('<!--@JS-->', () => 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…');
}
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
// One-time texture prep: NASA public-domain sources -> small WebP files in ./assets.
// Usage: node mobile/vision/prep-textures.mjs <dir-with-downloads>
// 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 ([email protected] 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 <download-dir>'); 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');
}
+567
View File
@@ -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; }
}
+165
View File
@@ -0,0 +1,165 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="claude:viewport-fit-sides" content="cover">
<title>God's Eye</title>
<!--@CSS-->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<linearGradient id="gGold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FBE7A9"/><stop offset=".5" stop-color="#E2B654"/><stop offset="1" stop-color="#A97A1F"/></linearGradient>
</defs>
<symbol id="i-radiowaves" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6M15.8 8.2a5.4 5.4 0 0 1 0 7.6M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17M12 3c-2.6 2.6-3.6 5.6-3.6 9s1 6.4 3.6 9M12 3c2.6 2.6 3.6 5.6 3.6 9s-1 6.4-3.6 9"/></symbol>
<symbol id="i-viewfinder" viewBox="0 0 24 24"><path d="M3.5 8V5.5a2 2 0 0 1 2-2H8M16 3.5h2.5a2 2 0 0 1 2 2V8M20.5 16v2.5a2 2 0 0 1-2 2H16M8 20.5H5.5a2 2 0 0 1-2-2V16"/><circle cx="12" cy="12" r="3.2"/></symbol>
<symbol id="i-threads" viewBox="0 0 24 24"><circle cx="5.5" cy="17.5" r="2.3"/><circle cx="18.5" cy="17.5" r="2.3"/><circle cx="12" cy="5.5" r="2.3"/><path d="M7.9 17.5h8.2M6.7 15.4l4.1-7.8M17.3 15.4l-4.1-7.8" stroke-dasharray="1.6 2.2"/></symbol>
<symbol id="i-person" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3.2"/><path d="M5.9 18.6c1.3-2.4 3.4-3.6 6.1-3.6s4.8 1.2 6.1 3.6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.3"/><path d="m15.3 15.3 5 5"/></symbol>
<symbol id="i-chevl" viewBox="0 0 24 24"><path d="M14.5 5 7.5 12l7 7"/></symbol>
<symbol id="i-chevr" viewBox="0 0 24 24"><path d="m9.5 5 7 7-7 7"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m5 9.5 7 7 7-7"/></symbol>
<symbol id="i-chevu" viewBox="0 0 24 24"><path d="m5 14.5 7-7 7 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5"/></symbol>
<symbol id="i-airplane" viewBox="0 0 24 24"><path d="M12 2.8c.9 0 1.4.9 1.4 2v4.6l7.1 4.2v1.9l-7.1-2.1v4.4l2.2 1.7v1.6L12 20.2l-3.6.9v-1.6l2.2-1.7v-4.4l-7.1 2.1v-1.9l7.1-4.2V4.8c0-1.1.5-2 1.4-2Z"/></symbol>
<symbol id="i-jet" viewBox="0 0 24 24"><path d="M12 2.5 13.6 8l6.4 6v1.6l-6.6-2.3-.6 4.2 2.4 2v1.5L12 20l-3.2 1v-1.5l2.4-2-.6-4.2L4 15.6V14l6.4-6Z"/></symbol>
<symbol id="i-helicopter" viewBox="0 0 24 24"><path d="M3 5.5h14M10 5.5V8"/><path d="M5.5 10.5h7.2a4.3 4.3 0 0 1 0 8.6H9.4a3.9 3.9 0 0 1-3.9-3.9Z"/><path d="M12.7 12.5H17l3-3.5M8 19.1v1.4M13 19.1v1.4M6 20.5h9"/></symbol>
<symbol id="i-propeller" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.8"/><path d="M12 10.2C11 6 11.3 3.5 12 3.5s1 2.5 0 6.7ZM13.6 12.9c3.2 2.9 4.2 5.2 3.6 5.6s-2.6-1.3-4.3-5.2ZM10.4 12.9c-3.2 2.9-4.2 5.2-3.6 5.6s2.6-1.3 4.3-5.2Z"/></symbol>
<symbol id="i-exclamation" viewBox="0 0 24 24"><path d="M12 3.5 21.5 20h-19Z"/><path d="M12 9.5v5M12 17.4v.1"/></symbol>
<symbol id="i-drone" viewBox="0 0 24 24"><circle cx="5.5" cy="5.5" r="2.5"/><circle cx="18.5" cy="5.5" r="2.5"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/><path d="m7.3 7.3 3 3M16.7 7.3l-3 3M7.3 16.7l3-3M16.7 16.7l-3-3"/><rect x="9.8" y="9.8" width="4.4" height="4.4" rx="1"/></symbol>
<symbol id="i-board" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="13" rx="2"/><path d="M6.5 8.5h5M6.5 11.5h7M6.5 14.5h4M16 8.5h1.5M16 11.5h1.5M16 14.5h1.5M9 20.5h6"/></symbol>
<symbol id="i-loop" viewBox="0 0 24 24"><rect x="3.5" y="7" width="17" height="10" rx="5"/><path d="m14 4.5 2.5 2.5L14 9.5"/></symbol>
<symbol id="i-ferry" viewBox="0 0 24 24"><path d="M4 14.5h16l-2.2 4.2H6.2Z"/><path d="M6.5 14.5V10h11v4.5M9 10V7h6v3M3 21c1.5 0 1.5-.8 3-.8s1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8"/></symbol>
<symbol id="i-anchor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><path d="M12 7v13.5M8 10.5h8M4.5 13.5c0 3.8 3.4 7 7.5 7s7.5-3.2 7.5-7M4.5 13.5l-1.3 1.6M19.5 13.5l1.3 1.6"/></symbol>
<symbol id="i-drop" viewBox="0 0 24 24"><path d="M12 3.2s6 6.6 6 11a6 6 0 0 1-12 0c0-4.4 6-11 6-11Z"/><path d="M9.2 15.2a2.9 2.9 0 0 0 2.6 2.6"/></symbol>
<symbol id="i-fish" viewBox="0 0 24 24"><path d="M3 12c3-4.2 7.5-5.7 11.5-4.4 2 .7 3.6 2.2 4.5 4.4-.9 2.2-2.5 3.7-4.5 4.4C10.5 17.7 6 16.2 3 12Z"/><path d="M19 12l2.5-3v6Z"/><circle cx="14.5" cy="11" r=".6" fill="currentColor"/></symbol>
<symbol id="i-eyeslash" viewBox="0 0 24 24"><path d="M2.8 12S6.2 5.8 12 5.8 21.2 12 21.2 12s-3.4 6.2-9.2 6.2S2.8 12 2.8 12Z"/><circle cx="12" cy="12" r="2.8"/><path d="M4 20 20 4"/></symbol>
<symbol id="i-cable" viewBox="0 0 24 24"><path d="M3 15c3 0 3-6 6-6s3 6 6 6 3-6 6-6"/><circle cx="3" cy="15" r="1.3" fill="currentColor"/><circle cx="21" cy="9" r="1.3" fill="currentColor"/></symbol>
<symbol id="i-satellite" viewBox="0 0 24 24"><rect x="9.5" y="9.5" width="5" height="5" rx="1" transform="rotate(45 12 12)"/><path d="m8.4 8.4-3-3M15.6 15.6l3 3"/><rect x="1.8" y="2.8" width="5" height="3" rx=".6" transform="rotate(45 4.3 4.3)"/><rect x="17.2" y="18.2" width="5" height="3" rx=".6" transform="rotate(45 19.7 19.7)"/><path d="M15 6.5a3.5 3.5 0 0 1 2.5 2.5M15.5 3.6a6.4 6.4 0 0 1 4.9 4.9"/></symbol>
<symbol id="i-iss" viewBox="0 0 24 24"><path d="M3 12h18M12 9v6"/><rect x="2.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="2.5" y="14" width="4" height="4.5" rx=".6"/><rect x="17.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="17.5" y="14" width="4" height="4.5" rx=".6"/><rect x="9.5" y="10" width="5" height="4" rx="1"/></symbol>
<symbol id="i-train" viewBox="0 0 24 24"><circle cx="4" cy="16" r="1.3"/><circle cx="8" cy="13.6" r="1.3"/><circle cx="12" cy="11.3" r="1.3"/><circle cx="16" cy="9" r="1.3"/><circle cx="20" cy="6.7" r="1.3"/></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 2.8c2.6 2.2 3.8 5.2 3.8 8.6v4.4H8.2v-4.4c0-3.4 1.2-6.4 3.8-8.6Z"/><circle cx="12" cy="9.5" r="1.4"/><path d="M8.2 12.5 5.5 15v3.2l2.7-1.6M15.8 12.5l2.7 2.5v3.2l-2.7-1.6M10.2 18.5c.3 1.3.9 2.2 1.8 2.8.9-.6 1.5-1.5 1.8-2.8"/></symbol>
<symbol id="i-debris" viewBox="0 0 24 24"><path d="m4 7 3-2 1 3ZM15 4l4 1-2 3ZM10 12l3-1 1 3-3 1ZM4 17l3 .5-1 2.5ZM17 16l3 1-1 3-3-1Z"/></symbol>
<symbol id="i-orbitpath" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="9.5" ry="4.2" transform="rotate(-25 12 12)" stroke-dasharray="2 2.4"/><circle cx="12" cy="12" r="3.2"/><circle cx="19.3" cy="8.4" r="1.5" fill="currentColor"/></symbol>
<symbol id="i-wave" viewBox="0 0 24 24"><path d="M2.5 12h3.2l1.6-4 2.4 9.5 2.8-14 2.6 13 1.8-6.5 1.4 2H21.5"/></symbol>
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M12 21c-3.9 0-6.5-2.6-6.5-6.2 0-4.3 3.6-6 4.4-10.8 2.6 1.7 4.1 4.2 4.1 6.6 1-.6 1.6-1.6 1.9-2.8 1.7 1.6 2.6 4 2.6 6.6 0 3.9-2.6 6.6-6.5 6.6Z"/><path d="M12 21c-1.7 0-2.8-1.2-2.8-2.8 0-1.9 1.6-2.6 2-4.6 1.6 1 2.6 2.6 2.6 4.4 0 1.8-.6 3-1.8 3Z"/></symbol>
<symbol id="i-perimeter" viewBox="0 0 24 24"><path d="M5 9.5 9 4.5l7 1.5 3.5 6-2.5 6.5-7 1L4.5 15Z" stroke-dasharray="2.2 2"/><circle cx="11.5" cy="12" r="2" /></symbol>
<symbol id="i-hurricane" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4"/><path d="M12 4.2c-4.8 0-8 3.2-8.3 6.2 2-2.2 4.4-3 6.5-2.6M12 19.8c4.8 0 8-3.2 8.3-6.2-2 2.2-4.4 3-6.5 2.6M9.6 12a7.6 7.6 0 0 0 8-7M14.4 12a7.6 7.6 0 0 0-8 7"/></symbol>
<symbol id="i-mountain" viewBox="0 0 24 24"><path d="M2.5 19.5 9.5 7l3.6 6.2 2.4-3.5 6 9.8Z"/><path d="m7.4 10.8 2.1 1.6 1.7-1.3"/></symbol>
<symbol id="i-aqi" viewBox="0 0 24 24"><path d="M3 9h11a3 3 0 1 0-3-3M3 14h15a3 3 0 1 1-3 3M3 19h7"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><path d="M3.5 8.2 15.6 5l1.3 4.8L4.8 13Z"/><path d="m16.2 7.4 3.8-1.2v4.4l-3.1.8M8 12.5l1 4H5.5M3 14.5v4"/></symbol>
<symbol id="i-seal" viewBox="0 0 24 24"><path fill="url(#gGold)" stroke="none" d="M12 1.6l2.1 1.6 2.6-.4 1 2.4 2.4 1-.4 2.6 1.6 2.1-1.6 2.1.4 2.6-2.4 1-1 2.4-2.6-.4L12 22.4l-2.1-1.6-2.6.4-1-2.4-2.4-1 .4-2.6L2.7 13l1.6-2.1-.4-2.6 2.4-1 1-2.4 2.6.4Z"/><circle cx="12" cy="12" r="6.2" fill="none" stroke="#7A5512" stroke-width=".8" opacity=".55"/><path d="m8.8 12.2 2.2 2.2 4.2-4.6" fill="none" stroke="#3B2A08" stroke-width="1.8"/></symbol>
<symbol id="i-binoculars" viewBox="0 0 24 24"><circle cx="6.5" cy="15.5" r="3.5"/><circle cx="17.5" cy="15.5" r="3.5"/><path d="M10 15h4M3.2 14 5 6.5a2 2 0 0 1 3.9.4V12M20.8 14 19 6.5a2 2 0 0 0-3.9.4V12"/></symbol>
<symbol id="i-cube" viewBox="0 0 24 24"><path d="M12 2.8 20 7.2v9.6L12 21.2 4 16.8V7.2Z"/><path d="M4 7.2 12 11.6l8-4.4M12 11.6v9.6"/></symbol>
<symbol id="i-live" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4"/><rect x="2.5" y="4.5" width="19" height="15" rx="3"/></symbol>
<symbol id="i-tram" viewBox="0 0 24 24"><rect x="5.5" y="5" width="13" height="12.5" rx="3"/><path d="M5.5 11.5h13M9 2.8h6M12 2.8V5M8 17.5 6.5 21M16 17.5l1.5 3.5"/><circle cx="9" cy="14.5" r=".8" fill="currentColor"/><circle cx="15" cy="14.5" r=".8" fill="currentColor"/></symbol>
<symbol id="i-car" viewBox="0 0 24 24"><path d="M4 15.5V12l2-4.8a2 2 0 0 1 1.8-1.2h8.4a2 2 0 0 1 1.8 1.2L20 12v3.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1Z"/><path d="M4 12h16M6.5 16.5v2M17.5 16.5v2"/><circle cx="7.5" cy="14" r=".8" fill="currentColor"/><circle cx="16.5" cy="14" r=".8" fill="currentColor"/></symbol>
<symbol id="i-bike" viewBox="0 0 24 24"><circle cx="6" cy="15.5" r="3.6"/><circle cx="18" cy="15.5" r="3.6"/><path d="m6 15.5 4-7h6l2 7M10 8.5l2.8 7h-6.8M9 6h2.5M15 5.5h2.5l-1.5 3"/></symbol>
<symbol id="i-rail" viewBox="0 0 24 24"><path d="M8 3 5 21M16 3l3 18M7.3 7.5h9.4M6.6 12h10.8M5.8 16.5h12.4"/></symbol>
<symbol id="i-ticket" viewBox="0 0 24 24"><path d="M3.5 7.5a1 1 0 0 1 1-1h15a1 1 0 0 1 1 1v2.3a2.2 2.2 0 0 0 0 4.4v2.3a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-2.3a2.2 2.2 0 0 0 0-4.4Z"/><path d="M14.5 6.5v11" stroke-dasharray="1.5 1.8"/></symbol>
<symbol id="i-plate" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="2"/><path d="M6 12h2M10 12h4M16 12h2"/></symbol>
<symbol id="i-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><path d="M12 12 18.4 5.6"/><circle cx="15.5" cy="9.2" r="1" fill="currentColor"/></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 18.5a4.2 4.2 0 0 1-.6-8.4A5.8 5.8 0 0 1 17.6 9a4.8 4.8 0 0 1-.6 9.5Z"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13.5 2.8 5 13.6h6.2L10.3 21.2l8.7-11h-6.3Z"/></symbol>
<symbol id="i-wind" viewBox="0 0 24 24"><path d="M3 9h11.5a2.8 2.8 0 1 0-2.8-2.8M3 13h16a2.8 2.8 0 1 1-2.8 2.8M3 17h7"/></symbol>
<symbol id="i-spaghetti" viewBox="0 0 24 24"><path d="M3 18C8 17 12 13 14 6M3 18c6-.5 10-3 15-9M3 18c6 0 11-1.5 17-6"/><circle cx="3" cy="18" r="1.4" fill="currentColor"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect x="4" y="3.5" width="16" height="7" rx="1.6"/><rect x="4" y="13.5" width="16" height="7" rx="1.6"/><path d="M7.5 7h.1M7.5 17h.1M11 7h5.5M11 17h5.5"/></symbol>
<symbol id="i-dam" viewBox="0 0 24 24"><path d="M3 5.5h6l2 13H3ZM11 18.5h10M13 12c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9M13 15.5c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 2.8 19.5 5.6v5.6c0 4.6-3 8.4-7.5 10-4.5-1.6-7.5-5.4-7.5-10V5.6Z"/><path d="M12 7.5v9M8 11.5h8"/></symbol>
<symbol id="i-jam" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M8 8a5.6 5.6 0 0 0 0 8M16 8a5.6 5.6 0 0 1 0 8M5.2 5.2a9.6 9.6 0 0 0 0 13.6M18.8 5.2a9.6 9.6 0 0 1 0 13.6M3.5 20.5l17-17"/></symbol>
<symbol id="i-frame" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2"/><path d="m3.5 15.5 5-4.5 4 3.5 3-2.5 5 4"/><circle cx="16" cy="9" r="1.5"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></symbol>
<symbol id="i-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m12 4.8 2.6 7.2H9.4Z" fill="#FF6A3D" stroke="none"/><path d="m12 19.2-2.6-7.2h5.2Z" fill="currentColor" stroke="none" opacity=".55"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="M12 3.5 21 8l-9 4.5L3 8Z"/><path d="m3 12 9 4.5 9-4.5M3 16l9 4.5 9-4.5"/></symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M10 3.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6ZM18 13.5c.3 1.9 1.1 2.7 3 3-1.9.3-2.7 1.1-3 3-.3-1.9-1.1-2.7-3-3 1.9-.3 2.7-1.1 3-3Z"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.6v14.8a.8.8 0 0 0 1.2.7l12-7.4a.8.8 0 0 0 0-1.4l-12-7.4A.8.8 0 0 0 7 4.6Z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="6" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/><rect x="14" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.6 2H4.4Z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/></symbol>
<symbol id="i-bellplus" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 8-5.7M18 12v4.5l1.6 2H4.4L6 16.5"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0M18.5 3v6M15.5 6h6"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><path d="M12 14.5V3M8 6.8 12 3l4 3.8M7 10H5.5v10.5h13V10H17"/></symbol>
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
<symbol id="i-heartf" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
<symbol id="i-bubble" viewBox="0 0 24 24"><path d="M4 11.5c0-4 3.6-7 8-7s8 3 8 7-3.6 7-8 7c-1 0-1.9-.1-2.8-.4L5 19.8l1.1-3.4C4.8 15.1 4 13.4 4 11.5Z"/></symbol>
<symbol id="i-location" viewBox="0 0 24 24"><path d="M20.5 3.5 3.5 10.6l7.3 2.6 2.6 7.3Z"/></symbol>
<symbol id="i-home" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.2"/><path d="M8.2 10.5V7.8a3.8 3.8 0 0 1 7.6 0v2.7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
<symbol id="i-hash" viewBox="0 0 24 24"><path d="M9.5 3.5 7.5 20.5M16.5 3.5l-2 17M4.5 8.5h16M3.5 15.5h16"/></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="M3.5 16a8.5 8.5 0 1 1 17 0"/><path d="m12 16 4.5-5.5"/><circle cx="12" cy="16" r="1.3" fill="currentColor"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.3 2"/></symbol>
<symbol id="i-wand" viewBox="0 0 24 24"><path d="m4 20 11-11M13.5 7.5l3 3"/><path d="M18 2.8v3M16.5 4.3h3M20.5 8.5v2M19.5 9.5h2M10.5 3v2M9.5 4h2"/></symbol>
<symbol id="i-signal" viewBox="0 0 24 24"><path d="M4 19.5v-3M9 19.5v-6M14 19.5v-9M19 19.5v-13"/></symbol>
<symbol id="i-arrowup" viewBox="0 0 24 24"><path d="M12 19.5v-15M6 10.5l6-6 6 6"/></symbol>
<symbol id="i-thumbup" viewBox="0 0 24 24"><path d="M7.5 10.5v9.5H4.5v-9.5ZM7.5 10.5l3.6-6.6a1.8 1.8 0 0 1 3.3 1.3l-.9 4.3h5.2a1.8 1.8 0 0 1 1.8 2.1l-1.2 6.6a2.2 2.2 0 0 1-2.2 1.8H7.5"/></symbol>
<symbol id="i-thumbdown" viewBox="0 0 24 24"><path d="M7.5 13.5V4H4.5v9.5ZM7.5 13.5l3.6 6.6a1.8 1.8 0 0 0 3.3-1.3l-.9-4.3h5.2a1.8 1.8 0 0 0 1.8-2.1L19.3 5.8A2.2 2.2 0 0 0 17.1 4H7.5"/></symbol>
<symbol id="i-gyro" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><ellipse cx="12" cy="12" rx="8.5" ry="3.2"/><ellipse cx="12" cy="12" rx="3.2" ry="8.5"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/><path d="M9.5 3v3.5M14.5 3v3.5M9.5 17.5V21M14.5 17.5V21M3 9.5h3.5M3 14.5h3.5M17.5 9.5H21M17.5 14.5H21"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="14.5" r="4"/><path d="m10.4 11.6 9.1-9.1M16.5 5.5l2.5 2.5M14 8l2 2"/></symbol>
<symbol id="i-flip" viewBox="0 0 24 24"><path d="M4 8.5h13.5l-3-3M20 15.5H6.5l3 3"/></symbol>
<symbol id="i-gallery" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="m3.5 15 4.5-4 4 3.5 3-2.5 5.5 4.5"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4M5 4.5h12l-2.5 4 2.5 4H5"/></symbol>
<symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19c.7-3 2.8-4.6 5.5-4.6s4.8 1.6 5.5 4.6"/><circle cx="16.5" cy="9" r="2.4"/><path d="M15.2 14.6c2.6-.3 4.6 1 5.3 3.9"/></symbol>
</svg>
<div id="app" class="app" data-tab="now" data-mode="">
<canvas id="gl" aria-label="Interactive 3D globe"></canvas>
<div class="globe-area"><div id="labels" class="labels"></div></div>
<div class="pane">
<section id="v-now" class="view on" aria-label="Now"></section>
<section id="v-threads" class="view" aria-label="Threads"></section>
<section id="v-you" class="view" aria-label="You"></section>
<section id="v-layers" class="view wide-only-view" aria-label="Layers"></section>
</div>
<!-- globe chrome -->
<div class="gtop gchrome flexy">
<button class="status glass" id="status"><span class="dot pulse"></span><span id="statusTxt">Live</span><span class="t num" id="statusTime"></span></button>
<div class="gbtns">
<button class="gbtn glass compass" id="btnCompass" aria-label="Reset north"><svg class="ic"><use href="#i-compass"/></svg></button>
<button class="gbtn glass" id="btnLayers" aria-label="Layers"><svg class="ic"><use href="#i-layers"/></svg></button>
<button class="gbtn glass" id="btnLens" aria-label="Lens"><svg class="ic"><use href="#i-sparkles"/></svg></button>
<button class="gbtn glass" id="btnHome" aria-label="Planet view"><svg class="ic"><use href="#i-globe"/></svg></button>
</div>
</div>
<div class="gbottom gchrome flexy" id="gbottom">
<div class="pick glass strong" id="pick"></div>
<div class="dock" id="dock"></div>
<div class="ribbon glass" id="ribbon"><canvas></canvas><div class="head"></div><div class="readout"><span id="rbTime" class="num"></span><span id="rbRel" class="rel num"></span></div><span class="fc">FORECAST</span><button class="nowbtn live" id="rbNow"><span class="dot"></span>NOW</button></div>
</div>
<button class="backbtn circ glass" id="back" aria-label="Back"><svg class="ic"><use href="#i-chevl"/></svg></button>
<div class="sitbtns" id="sitbtns"><button class="circ glass" id="sitFollow" aria-label="Follow"><svg class="ic"><use href="#i-bellplus"/></svg></button><button class="circ glass" id="sitShare" aria-label="Share"><svg class="ic"><use href="#i-share"/></svg></button></div>
<div class="sheet glass strong" id="sheet"><div class="grab" id="grab"></div><div class="sc" id="sheetC"></div></div>
<div class="ride" id="ride"></div>
<section id="v-capture" class="view" aria-label="Capture"></section>
<div class="cc" id="cc"></div>
<div class="search" id="search"></div>
<div class="acc-host" id="accHost"><button class="accessory glass" id="acc"></button></div>
<nav class="tabwrap" id="tabwrap">
<div class="tabbar glass" id="tabbar" role="tablist">
<div class="tablens" id="tablens"></div>
<button class="tab on" data-tab="now" role="tab"><svg class="ic"><use href="#i-radiowaves"/></svg><span>Now</span></button>
<button class="tab" data-tab="globe" role="tab"><svg class="ic"><use href="#i-globe"/></svg><span>Globe</span></button>
<button class="tab" data-tab="capture" role="tab"><svg class="ic"><use href="#i-viewfinder"/></svg><span>Capture</span></button>
<button class="tab" data-tab="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Threads</span></button>
<button class="tab" data-tab="you" role="tab"><svg class="ic"><use href="#i-person"/></svg><span>You</span></button>
</div>
<button class="accessory glass" id="accMini"></button>
<button class="searchbtn glass" id="btnSearch" aria-label="Search"><svg class="ic"><use href="#i-search"/></svg></button>
</nav>
<div class="morph" id="morph"></div>
<div class="seal-ov" id="sealOv"></div>
<div class="inspector" id="inspector"></div>
<div class="toastp glass" id="toast"></div>
<div class="concept-tag">Concept · illustrative data</div>
<div class="cold" id="cold">
<button class="skip glass" id="skip">Skip</button>
<svg class="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/></svg>
<div class="word">God’s Eye</div>
<div class="tag">The window to the world.</div>
</div>
</div>
<!--@TEX-->
<!--@JS-->
+240
View File
@@ -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 = '') => `<svg class="ic ${cls}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 });
+281
View File
@@ -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 });
+870
View File
@@ -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;
};
+578
View File
@@ -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 });
+240
View File
@@ -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); };
+189
View File
@@ -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 });
};
+227
View File
@@ -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)}<small>km/h</small>`, `${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 ? '<b>Now</b>' : (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)}<small>hotspots</small>`, '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 ? '<b>Now</b>' : (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: () => [`38<small>aircraft</small>`, '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)}<small>h</small> ${Math.floor((s % 3600) / 60)}<small>m</small>` : '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} <small>${b.perH} mvts/h</small>`, `${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}<small>in circuits</small>`, '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: () => [`<span class="num">T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}</span>`, '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: () => ['23<small>obs</small> · 9<small>hyp</small>', '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,311<small>captures</small>', '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: () => ['41<small>clips</small> · 3<small>helis</small>', '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}<small>waiting</small>`, '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 6<small>tonight</small>', '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: () => ['212<small>drops</small>', '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: () => ['260<small>trackers</small>', '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: () => ['8<small>hot spots</small>', '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: () => ['24<small>satellites</small>', '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: () => ['7700<small>emergency</small>', '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,912<small>transits/day</small>', '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) }; };
+147
View File
@@ -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 = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span><svg class="spk" width="54" height="22" viewBox="0 0 54 22">${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}</svg><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
$('#acc').innerHTML = html;
$('#accMini').innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span>`;
};
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 `<path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${l[0]}" cy="${l[1]}" r="2.4" fill="${col}"/>`;
}
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: `<i style="--c:${GE.FAM[s.fam].color}"></i>${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: `<b>${esc(GE.world.sel.title)}</b>`, 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);
};
+162
View File
@@ -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 ? `<span class="badge d4">4D</span><span class="badge replay">Thread</span>` : s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : `<span class="badge replay">${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}</span>`;
const right = s.thread ? `<span class="stack">${['tamang', 'hydro', 'kora', 'geo'].map((k) => `<span class="avatar sm" style="--h:${GE.PEOPLE[k].hue}">${GE.PEOPLE[k].h[0].toUpperCase()}</span>`).join('')}</span>` : `<span class="r">${ic('eye', 'xs')}<span class="num">${fmt.k(s.watchers)}</span></span>`;
return `<div class="thumb" style="aspect-ratio:${1}/${s.aspect}"><canvas width="360" height="${Math.round(360 * s.aspect)}"></canvas>
<div class="tl">${badge}</div><div class="tr" style="--c:${c}">${ic(famIcon(s.fam), 'sm')}</div>
<div class="bl"><div class="metric num" data-m>${m}</div><div class="mlabel num" data-ml>${ml}</div></div></div>
<div class="meta"><div class="ttl">${esc(s.title)}</div><div class="src"><span class="l"><span style="width:6px;height:6px;border-radius:50%;background:${c};flex:none"></span><span data-age>${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}</span></span>${right}</div>
${s.ride ? `<button class="cta" data-ride>${ic('helicopter', 'sm')}Jump into the chopper</button>` : ''}</div>`;
}
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 = `<div class="navbar">Now</div>
<div class="now-hero">
<div class="lt"><div class="kicker"><span class="dot pulse"></span><span>${kick}</span>·<span class="num" id="nowClock">${fmt.hm(Date.now())} UTC</span></div>
<div class="row"><h1>Now</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div></div>
<div class="livebar"><span><b class="num" id="cSit">${GE.SIT.list.length}</b> situations</span><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num" id="cSat">${fmt.n(GE.SATS.length)}</b> satellites</span></div>
</div>
<div class="now-body"><div class="chips" id="nowChips">
${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => `<button class="chip ${NOWV.filter === k ? 'on' : ''}" data-f="${k}">${l}</button>`).join('')}
</div><div id="nowSecs"></div></div>`;
$('#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;
};
+196
View File
@@ -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 &amp; 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 += `<path d="${up}${lo}Z" fill="rgba(124,196,255,.12)"/>`; }
s += `<path d="${path(0, split)}" fill="none" stroke="#DCE6F5" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
if (split < n - 1) s += `<path d="${path(split, n - 1)}" fill="none" stroke="#8AB4F8" stroke-width="1.5" stroke-dasharray="3 3"/>`;
const iMax = d.indexOf(Math.max(...d)), iMin = d.indexOf(Math.min(...d));
const f = sr.fmt || ((v) => v);
s += `<circle cx="${X(iMax)}" cy="${Y(d[iMax])}" r="2.2" fill="#FF8A6B"/><text x="${X(iMax)}" y="${Y(d[iMax]) - 5}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMax])}</text>`;
if (iMin !== iMax) s += `<circle cx="${X(iMin)}" cy="${Y(d[iMin])}" r="2.2" fill="#7CC4FF"/><text x="${X(iMin)}" y="${Y(d[iMin]) + 13}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMin])}</text>`;
const ln = split; s += `<circle cx="${X(ln)}" cy="${Y(d[ln])}" r="3" fill="#fff"/><text x="${X(ln) + 6}" y="${Y(d[ln]) + 3.5}" font-size="10.5" font-weight="700" fill="#fff" font-family="var(--font)">${f(d[ln])}</text>`;
if (split < n - 1) s += `<line x1="${X(split)}" x2="${X(split)}" y1="2" y2="${hgt - 2}" stroke="rgba(255,255,255,.18)" stroke-dasharray="2 2"/><text x="${X(split) + 3}" y="${hgt - 1}" font-size="8.5" fill="#8AB4F8" font-family="var(--font)">forecast</text>`;
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
}
GE.sparkSVG = sparkSVG;
function barsSVG(items, w) {
const hgt = items.length * 22; let s = '';
items.forEach(([k, v], i) => { const y = i * 22; s += `<text x="0" y="${y + 14}" font-size="12" fill="rgba(232,238,248,.75)" font-family="var(--font)">${k}</text><rect x="92" y="${y + 6}" width="${(w - 132) * v}" height="10" rx="3" fill="rgba(124,196,255,.55)"/><text x="${92 + (w - 132) * v + 6}" y="${y + 15}" font-size="11" fill="rgba(232,238,248,.55)" font-family="var(--font)">${Math.round(v * 100)}</text>`; });
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
}
SH.render = function (s) {
const c = GE.FAM[s.fam].color; const el = $('#sheetC');
const ms = s.metrics();
const live = s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : s.d4 ? `<span class="badge d4">4D</span>` : `<span class="badge replay">Replay</span>`;
const acts = [
`<button class="act pri" data-a="follow">${ic('bellplus')}Follow</button>`,
s.ride ? `<button class="act gold" data-a="ride">${ic('helicopter')}Ride</button>` : `<button class="act" data-a="replay">${ic('clock')}Replay</button>`,
`<button class="act" data-a="eye">${ic('video')}Nearest eye</button>`,
`<button class="act" data-a="thread">${ic('threads')}Thread</button>`,
].join('');
const b = s.brief || {};
el.innerHTML = `
<div class="sh-hd"><div class="t"><div style="display:flex;gap:6px;align-items:center"><span class="badge fam" style="--c:${c}"><i></i>${GE.FAM[s.fam].name}</span>${live}<span style="font-size:12px;color:var(--text3)" class="num" data-sage>${fmt.ago(Date.now() - s.updatedAt)}</span></div>
<h2>${esc(s.title)}</h2><div class="pl">${esc(s.place)} · <span class="mono">${fmt.ll(s.lat, s.lon, 2)}</span></div></div></div>
<div class="metrics" id="shMetrics">${ms.map((m) => `<div class="metric"><div class="v">${m.v}<small>${m.u}</small></div><div class="l">${m.l}</div><div class="s">${m.s}</div></div>`).join('')}</div>
<div class="acts sh-acts">${acts}</div>
<div class="brief">
${b.lede ? `<p>${esc(b.lede)}</p>` : ''}
${b.series?.length ? `<h3>Signals</h3><div id="shSeries"></div>` : ''}
${b.multiples ? `<h3>${esc(b.multiples.label)}</h3><div class="multi" id="shMulti"></div>` : ''}
<h3>Evidence</h3><div class="evid" id="shEvid"></div>
<h3>Sources</h3><div class="srcs">${(b.sources || []).map(([n, a, st]) => `<div class="r"><span class="st ${st === 'stale' ? 'stale' : ''}"></span><b style="font-weight:500">${esc(n)}</b><span>${esc(a)}</span></div>`).join('')}
<div class="r"><span class="st"></span><b style="font-weight:500">Sun, terminator &amp; time</b><span>computed</span></div></div>
${s.thread ? `<div style="margin-top:18px"><button class="btn pri" data-a="thread" style="width:100%">${ic('threads')}Open the reconstruction</button></div>` : ''}
<p style="margin-top:20px;font-size:11.5px;color:var(--text3)">Concept · illustrative data. People, handles, posts and claims shown here are fictional.</p>
</div>`;
const w = Math.max(240, (APP.isWide() ? $('#sheet').clientWidth : $('#app').clientWidth) - 36);
if (b.series?.length) $('#shSeries').innerHTML = b.series.map((sr) => `<div class="sline"><span class="lab">${esc(sr.label)}</span><span class="val">${(sr.fmt || ((v) => v))(sr.data[sr.split ?? sr.data.length - 1])} ${esc(sr.unit)}</span>${sparkSVG(sr, w)}<div class="src"><span>${esc(sr.src)}</span><span>${sr.band ? '± band = uncertainty' : ''}</span></div></div>`).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', `<svg class="seal"><use href="#i-seal"/></svg>`);
const card = h('div', { class: 'ev' }, im, h('div', { class: 'cap', html: `<b>@${p.h}</b>${['Signed · IMU ✓ GPS ±5 m', 'Signed · horizon ✓', 'Signed · 12 s clip', 'Signed · heading ✓', 'Signed · audio sync'][i]}<br><span class="num">${fmt.ago((i + 1) * 1000 * 60 * (7 + i * 13))}</span>` }));
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}<small>${ms[i].u}</small>`; 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`); }
};
+221
View File
@@ -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 `<button class="fam glass ${on ? '' : 'off'} ${GUI.solo === f ? 'solo' : ''}" data-f="${f}" style="--c:${F.color}">${ic(F.icon)}${F.name}<span class="n num">${fmt.k(GUI.famCount(f))}</span></button>`; }).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(`<span style="color:${GE.FAM[f].color}">●</span> 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 = `<div class="lenses">${LENSES.map(([id, n, bg]) => `<button class="lens ${W.lens === id ? 'on' : ''}" data-lens="${id}"><span class="sw" style="background:${bg}"></span>${n}</button>`).join('')}</div>`;
const fams = FAMS.map((f) => { const F = GE.FAM[f]; const ls = W.layers.filter((l) => l.fam === f);
return `<section class="ccfam" id="ccf-${f}" style="--c:${F.color}"><div class="fh" data-fh="${f}"><i></i>${F.name}<span style="color:var(--text4);font-weight:600">${ls.length}</span><button class="solo" data-solo="${f}">${GUI.solo === f ? 'Restore' : 'Solo'}</button></div><div class="ccgrid">${ls.map((l) => `<button class="tile ${l.on ? 'on' : ''}" data-l="${l.id}"><span class="ti">${ic(l.icon || F.icon)}</span><span><div class="tn">${esc(l.name)}</div><div class="tc"><span class="st ${l.id === 'models' || l.id === 'imagery' ? 'stale' : ''}"></span><span class="num">${l.count ? fmt.n(l.count) : '—'}</span>${l.id === 'models' || l.id === 'imagery' ? ' · 3 h' : ''}</div></span></button>`).join('')}</div></section>`; }).join('');
return `<div class="cch"><div><h2>Layers</h2><p>${W.layers.length} live layers in 8 families · long-press a family to solo</p></div>${inPane ? '' : `<button class="circ glass" id="ccX" aria-label="Close">${ic('x')}</button>`}</div><div class="stratum" style="margin-top:4px">Lens</div>${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 = `<div style="padding:calc(var(--sat) + 16px) 16px 140px">${GUI.ccHTML(true)}</div>`; 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 ? '<span class="dot pulse"></span>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 = `<div><b>${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)}</b>ft</div><div><b>${Math.round(p.meta.f.speed / 1.852)}</b>kt</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div><div><b>${Math.round(p.meta.prog * 100)}%</b>route</div>`;
else if (p.kind === 'ship') kv = `<div><b>${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
else if (p.kind === 'heli') kv = `<div><b>${Math.round(p.altKm * 3281)}</b>ft AGL</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div>`;
else kv = `<div><b class="mono">${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
const canRide = p.kind === 'flight' || p.kind === 'heli';
el.style.setProperty('--c', GE.FAM[famOf].color);
el.innerHTML = `<div class="hd"><span class="ico">${ic(icn)}</span><div class="tt"><b>${esc(p.title)}</b><span>${esc(p.subtitle || '')}</span></div><button class="x" aria-label="Close">${ic('x', 'sm')}</button></div><div class="kv">${kv}</div>
<div class="acts"><button class="act pri" data-p="follow">${ic('bellplus')}Follow</button><button class="act ${canRide ? 'gold' : ''}" data-p="ride" ${canRide ? '' : 'disabled style="opacity:.4"'}>${ic(p.kind === 'heli' ? 'helicopter' : 'airplane')}Ride</button><button class="act" data-p="eye">${ic('video')}Nearest eye</button><button class="act" data-p="thread">${ic('threads')}Thread</button></div>`;
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 = `<div class="res" id="sres"></div><div class="bar"><div class="field glass">${ic('search')}<input id="sq" placeholder="Places, situations, layers, callsigns" autocomplete="off" enterkeyhint="search"></div><button class="circ glass" id="sx" style="width:52px;height:52px">${ic('x')}</button></div>`;
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: `<span class="ri" style="--c:${it.c}">${ic(it.i, 'sm')}</span><span style="text-align:left;min-width:0"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.t)}</span><small>${esc(it.s)}</small></span>` }); 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());
};
+93
View File
@@ -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 = `<div class="hud" id="hud"><svg id="hudSvg"></svg><div class="tape" style="left:14px" id="hSpd"></div><div class="tape" style="right:14px;text-align:right" id="hAlt"></div></div>
<div class="top"><button class="circ glass" id="rideX" aria-label="Exit ride">${ic('chevd')}</button><div class="who glass"><span class="badge live" style="background:rgba(255,69,58,.2)"><span class="dot pulse"></span>Ride</span><span style="min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis"><b>${esc(nm)}</b> · ${esc(tp)}</span><span style="margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--text2)">${ic('people', 'sm')}<span class="num" id="rideN">2,318</span></span></div></div>
<div class="chat" id="chat"></div><div class="react" id="react"></div>
<div class="compose"><div class="in glass"><input id="rideIn" placeholder="Say something to everyone riding…" enterkeyhint="send" maxlength="120"></div><button class="circ glass" id="rideHeart" aria-label="React" style="width:48px;height:48px;color:var(--live)">${ic('heartf')}</button></div>`;
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 = `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span><div class="bx glass" ${mine ? 'style="background:rgba(124,196,255,.22)"' : ''}><b>@${esc(p.h)}${who === 'rotorwash_ret' ? ' · former instructor' : ''}</b>${esc(text)}</div>`;
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 transform="rotate(${roll.toFixed(2)} ${cx} ${cy})" stroke="rgba(190,255,200,.8)" fill="rgba(190,255,200,.85)" font-size="10" font-family="var(--mono)">`;
g += `<line x1="${cx - 150}" y1="${cy + pitchPx}" x2="${cx - 40}" y2="${cy + pitchPx}" stroke-width="1.5"/><line x1="${cx + 40}" y1="${cy + pitchPx}" x2="${cx + 150}" y2="${cy + pitchPx}" stroke-width="1.5"/>`;
for (const pd of [-10, -5, 5, 10]) { const y = cy + pitchPx - pd * 6; g += `<line x1="${cx - 50}" y1="${y}" x2="${cx - 26}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><line x1="${cx + 26}" y1="${y}" x2="${cx + 50}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><text x="${cx + 56}" y="${y + 3}" stroke="none">${pd}</text>`; }
g += `</g>`;
g += `<g stroke="rgba(190,255,200,.95)" fill="none" stroke-width="1.6"><circle cx="${cx}" cy="${cy}" r="5"/><line x1="${cx - 18}" y1="${cy}" x2="${cx - 5}" y2="${cy}"/><line x1="${cx + 5}" y1="${cy}" x2="${cx + 18}" y2="${cy}"/><line x1="${cx}" y1="${cy - 5}" x2="${cx}" y2="${cy - 12}"/></g>`;
// heading tape
const ty = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sat')) + 70;
g += `<g font-size="11" font-family="var(--mono)" fill="rgba(190,255,200,.85)" stroke="rgba(190,255,200,.6)" text-anchor="middle">`;
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 += `<line x1="${x}" y1="${ty}" x2="${x}" y2="${ty + (dd % 10 === 0 ? 9 : 5)}"/>`; if (dd % 30 === 0) g += `<text x="${x}" y="${ty + 22}" stroke="none">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? String(dd / 10).padStart(2, '0')}</text>`; }
g += `<rect x="${cx - 24}" y="${ty - 22}" width="48" height="18" rx="4" fill="rgba(0,20,8,.35)"/><text x="${cx}" y="${ty - 9}" stroke="none" font-weight="700">${String(Math.round(hdg)).padStart(3, '0')}</text></g>`;
svg.innerHTML = g;
$('#hSpd').innerHTML = `${Math.round(s.spd)}<small>KT GS</small>`;
$('#hAlt').innerHTML = `${fmt.n(Math.round(s.alt * 3281 / 10) * 10)}<small>FT ${RIDE.hc ? 'AGL' : 'MSL'} · ${s.vs > 0 ? '+' : ''}${Math.round(s.vs / 50) * 50} FPM</small>`;
};
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 });
};
+178
View File
@@ -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 = `<div class="cap-ui">
<svg class="compass-tape" id="ctape"></svg>
<div class="callouts" id="callouts"></div>
<div class="cap-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}</span></div>
<div class="sensors glass ${CAP.mode === 'witness' ? 'on' : ''}" id="sensors"></div>
<div class="cap-bottom">
<div class="zooms glass">${[0.5, 1, 3].map((z) => `<button data-z="${z}" class="${CAP.zoom === z ? 'on' : ''}">${z === 0.5 ? '.5' : z}×</button>`).join('')}</div>
<div class="dial" id="dial"><button data-m="look" class="${CAP.mode === 'look' ? 'on' : ''}">Look</button><button data-m="witness" class="${CAP.mode === 'witness' ? 'on' : ''}">Witness</button></div>
<div class="shutrow"><button class="lastcap" id="lastcap" aria-label="Last capture" style="${last ? `background-image:url(${last.thumb})` : ''}">${last ? '<svg class="seal"><use href="#i-seal"/></svg>' : ''}</button>
<button class="shutter ${CAP.mode === 'look' ? 'look' : ''}" id="shutter" aria-label="${CAP.mode === 'look' ? 'Switch to Witness' : 'Capture and sign'}"><i></i></button>
<button class="circ glass" id="capFlip" aria-label="Layers in view">${ic('layers')}</button></div>
</div></div>`;
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 = `<g font-family="var(--font)" font-size="11" text-anchor="middle" fill="rgba(255,255,255,.85)" stroke="rgba(255,255,255,.55)">`;
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 += `<line x1="${x}" x2="${x}" y1="2" y2="${dd % 15 === 0 ? 11 : 6}"/>`; if (dd % 15 === 0) g += `<text x="${x}" y="25" stroke="none" font-weight="${dd % 90 === 0 ? 800 : 500}" fill="${dd === 0 ? '#FF6A3D' : 'rgba(255,255,255,.8)'}">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? dd}</text>`; }
g += `<path d="M${w / 2 - 5} 0 L${w / 2 + 5} 0 L${w / 2} 6Z" fill="#FFD60A" stroke="none"/><text x="${w / 2}" y="38" font-size="10" stroke="none" fill="rgba(255,255,255,.6)">${Math.round(hdg)}° · pitch ${Math.round(CAP.pitch * R2D)}°</text></g>`;
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 = `<div class="reticle" style="--c:${it.c}"></div><div class="callout glass" style="--c:${it.c}"><span class="ci">${ic(it.i, 'sm')}</span><span><b>${esc(it.t)}</b><span data-s></span></span></div>`; 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 = `<div style="display:flex;justify-content:space-between"><span>IMU 50 Hz</span><b>${(imu[39] * 0.12).toFixed(3)} g</b></div><svg viewBox="0 0 140 26">${GE.sparkPath(imu, 140, 26, '#5CF2A0', 1.3)}</svg><div style="display:flex;justify-content:space-between"><span>GPS</span><b>±4 m · ${SPOT.lat.toFixed(5)}, ${SPOT.lon.toFixed(5)}</b></div><div style="display:flex;justify-content:space-between"><span>Heading</span><b>${Math.round(hdg)}° true</b></div><div style="display:flex;justify-content:space-between;gap:10px"><span>Key</span><b>P-256 · ${KEY.pair ? 'device-bound' : '…'}</b></div>`;
}
};
/* ---------- 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 = `<div class="bundle glass strong"><div class="ph" id="bph"><svg class="bigseal" id="bigseal" viewBox="0 0 24 24"><use href="#i-seal"/></svg></div>
<div class="steps">
<div class="step" data-s="0"><span class="ck">${ic('check', 'xs')}</span><div><b>Frame hashed</b> · SHA-256 of ${fmt.n(b.bytes.length)} JPEG bytes<div class="h" id="sh0">…</div></div></div>
<div class="step" data-s="1"><span class="ck">${ic('check', 'xs')}</span><div><b>Session sealed</b> · IMU 150 samples @ 50 Hz · GPS ±4 m · heading ${b.session.heading}°<svg viewBox="0 0 300 30" preserveAspectRatio="none">${GE.sparkPath(b.session.imu.map((x) => x.g + x.b * 0.02), 300, 30, '#5CF2A0', 1.2)}</svg></div></div>
<div class="step" data-s="2"><span class="ck">${ic('check', 'xs')}</span><div><b>Chained</b> to this device’s previous bundle<div class="h">prev ${b.prev.slice(0, 24)}…</div></div></div>
<div class="step" data-s="3"><span class="ck">${ic('check', 'xs')}</span><div><b>Bundle hash</b><div class="h" id="sh3">…</div></div></div>
<div class="step" data-s="4"><span class="ck">${ic('check', 'xs')}</span><div><b>Signed</b> · ECDSA P-256, key generated on device, non-extractable<div class="h" id="sh4">…</div></div></div>
</div>
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('cube', 'sm')}Drop as hologram here</button></div>
<div class="btnrow" id="bbtns2" style="opacity:.35;pointer-events:none"><button class="btn" id="bver">${ic('check', 'sm')}Verify</button><button class="btn" id="bdone">Done</button></div>
<p style="margin:0;font-size:11px;color:var(--text3);text-align:center">None of this proves truth. It raises the cost of lying.</p></div>`;
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(); });
};
+288
View File
@@ -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 = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Threads</div><div class="lt"><div class="kicker">${ic('seal', 'xs')} Signed evidence · open ledgers</div><div class="row"><h1>Threads</h1><button class="avatar md" style="--h:205" id="thMe">Y</button></div></div>
<div class="livebar" style="padding-bottom:14px"><span class="presence"><span class="stack">${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `<span class="avatar sm on" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span><span><b class="num">37</b> people reconstructing now</span></div>
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `<article class="tcard" data-t="${T.id}" data-id="${T.sit}"><div class="thumb"><canvas width="640" height="360"></canvas><div class="bl"><div><h3>${esc(T.title)}</h3><p>${esc(T.place)} · ${esc(T.date)}</p></div><span class="badge d4">4D</span></div></div><div class="stats"><div><b>${T.obs.length}</b>observations</div><div><b>${T.hyps.length}</b>hypotheses</div><div><b>${THR.ledgers[T.id]?.length || '–'}</b>ledger</div><span class="stack">${T.people.slice(0, 4).map((k) => `<span class="avatar sm" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></div></article>`; }).join('')}
<div style="padding:6px 14px 150px"><button class="btn" style="width:100%" id="newThread">${ic('plus', 'sm')}Start a reconstruction</button><p style="font-size:11.5px;color:var(--text3);text-align:center;margin-top:12px">Concept · illustrative data. Witness posts, handles and claims are fictional.</p></div></div>
<div class="ws" id="ws"></div>`;
$('#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 = `<div class="wsh"><button class="circ glass" id="wsBack" aria-label="Back">${ic('chevl')}</button><div class="tt glass"><span style="min-width:0;flex:1"><b>${esc(T.title)}</b><span>${esc(T.place)} · ${esc(T.date)}</span></span><span class="presence"><span class="stack">${T.people.slice(0, 4).map((k, i) => `<span class="avatar sm ${i < 3 ? 'on' : ''}" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span></div></div>
<div class="body glass strong"><div class="seg" id="wsSeg">${[['obs', 'Observations'], ['hyp', 'Hypotheses'], ['nar', 'Narrative'], ['led', 'Ledger']].map(([k, l]) => `<button data-s="${k}" class="${seg === k ? 'on' : ''}">${l}</button>`).join('')}</div><div class="wsc" id="wsc"></div>
${seg === 'hyp' ? `<div class="composer glass strong" id="composer"><div class="sug">${T.suggestions.map((s) => `<button>${esc(s)}</button>`).join('')}</div><div class="row"><textarea id="aiIn" rows="1" placeholder="Ask AI to refine this reconstruction…"></textarea><button class="send" id="aiSend" aria-label="Send">${ic('arrowup')}</button></div></div>` : ''}</div>`;
$('#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 = `<div class="stratum">${ic('lock', 'xs')} Stratum 1 · Observations — immutable, hashed, sourced</div>` + T.obs.map((o) => `<div class="obs" data-o="${o.id}" style="--c:${GE.FAM[o.f].color}"><span class="oi">${ic(o.i)}</span><div><div class="ot"><span class="tagid">${o.id}</span> ${esc(o.t)}</div><div class="om"><span>${esc(o.src)}</span><span class="num">${fmt.utc(o.at)}</span>${o.meta.map((m) => `<span>${esc(m)}</span>`).join('')}<span class="mono">sha256 ${o.hash ? o.hash.slice(0, 12) + '…' : '…'}</span></div></div></div>`).join('');
if (seg === 'hyp') { c.innerHTML = `<div class="stratum">${ic('people', 'xs')} Stratum 2 · Hypotheses — cite, agree, dispute, supersede</div><div id="hypList"></div>`; 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 `<svg viewBox="0 0 ${w} 14" preserveAspectRatio="none"><rect x="0" y="5" width="${w}" height="4" rx="2" fill="rgba(255,255,255,.08)"/><rect x="${h.ci[0] * w}" y="3" width="${(h.ci[1] - h.ci[0]) * w}" height="8" rx="4" fill="${h.ai ? 'rgba(183,156,255,.3)' : 'rgba(124,196,255,.25)'}"/><rect x="${h.conf * w - 1.5}" y="1" width="3" height="12" rx="1.5" fill="${h.ai ? '#B79CFF' : '#7CC4FF'}"/></svg>`; };
THR.hypHTML = function (hy) {
const p = P[hy.by] || P.ai;
return `<div class="hyp ${hy.ai ? 'ai' : ''}" data-h="${hy.id}"><div class="hh">${hy.ai ? `<span class="avatar sm ai">${ic('sparkles', 'xs')}</span>` : `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span>`}<b>${hy.ai ? 'AI proposal' : '@' + p.h}</b><span class="tagid">${hy.id}</span><span class="stt ${hy.status}">${hy.status}</span></div>
<div class="hb" data-text>${esc(hy.text)}</div>
<div class="conf"><span>confidence</span>${confSVG(hy)}<b class="num" style="color:var(--text)">${Math.round(hy.conf * 100)}%</b></div>
<div class="cites">${hy.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}${hy.prompt ? `<span style="font-size:11.5px;color:var(--text3)">prompt: “${esc(hy.prompt)}” · ${esc(hy.model || 'claude')}</span>` : ''}</div>
<div class="hf"><button class="vote ${hy.myVote === 1 ? 'on' : ''}" data-v="1">${ic('thumbup', 'sm')}<span class="num">${hy.agree + (hy.myVote === 1 ? 1 : 0)}</span></button><button class="vote dis ${hy.myVote === -1 ? 'on' : ''}" data-v="-1">${ic('thumbdown', 'sm')}<span class="num">${hy.dispute + (hy.myVote === -1 ? 1 : 0)}</span></button><button class="vote" data-v="0" style="margin-left:auto">${ic('bubble', 'sm')}Cite</button></div></div>`;
};
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 = `<div class="stratum">${ic('play', 'xs')} Stratum 3 · Narrative — a camera path through space-time</div><div class="playbar"><button class="playbtn" id="play" aria-label="Play">${ic(THR.ws.playing ? 'pause' : 'play')}</button><div style="flex:1"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-bottom:6px"><span class="num" id="narT">${fmt.utc(GE.world.simMs)}</span><span>${T.beats.length} beats</span></div><div class="prog"><i id="narP"></i></div></div></div><div class="narr">${T.beats.map((b, i) => `<div class="beat" data-b="${i}"><span class="bt">${b.t}<br><span style="font-size:10px;color:var(--text3)">UTC</span></span><div><p>${esc(b.text)}</p><div class="cites">${b.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}</div></div></div>`).join('')}</div>`;
$('#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 = `<div class="stratum">${ic('link', 'xs')} Ledger — SHA-256 hash chain · ${L.length} entries</div><div class="btnrow" style="margin:4px 0 10px"><button class="btn sm pri" id="lv">${ic('check', 'sm')}Verify chain</button><button class="btn sm" id="lt">${THR._tampered ? 'Restore entry #3' : 'Tamper with #3'}</button><button class="btn sm" id="lx">${ic('share', 'sm')}Export</button></div><div class="ledger" id="led">${L.map((e) => `<div class="le" data-i="${e.i}"><div class="lh"><span class="ix">#${e.i}</span><span class="ty">${esc(e.type)} <span>· @${esc(e.actor)}</span></span><span class="vk" data-vk></span></div><div style="margin-left:34px" class="hs">${e.hash.slice(0, 20)}… ← ${e.prev.slice(0, 8)}…</div><pre>${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))}</pre></div>`).join('<div class="chain"></div>')}</div>`;
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`); });
};
+68
View File
@@ -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 = `<div style="background:var(--bg);min-height:100%;padding-bottom:150px"><div class="navbar">You</div>
<div class="lt"><div class="kicker">${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}</div><h1>You</h1></div>
<div class="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your window</h2><div style="color:var(--text2);font-size:13px;margin:2px 0 8px">Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}</div><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
<div class="stats4"><div><b>${caps.length}</b>captures</div><div><b>${holos.length}</b>holograms</div><div><b>${Object.values(YOU.follows).filter(Boolean).length}</b>following</div><div><b>${caps.length ? '100%' : '—'}</b>verified</div></div>
<div class="sec-h"><h2>Captures</h2><span>sealed on this device</span></div>
<div class="capgrid">${caps.length ? caps.map((c, i) => `<div class="capc" data-c="${i}"><div class="im" style="background-image:url(${c.thumb})"><svg class="seal"><use href="#i-seal"/></svg></div><div class="cm"><b>${esc(c.place)}</b><span class="num">${fmt.utc(c.at)}</span><div class="mono">${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}</div></div></div>`).join('') : `<div style="grid-column:1/-1;color:var(--text3);font-size:14px;padding:6px 4px">No captures yet — open Capture › Witness and press the shutter.</div>`}</div>
<div class="sec-h"><h2>Holograms</h2><span>left in places</span></div>
<div class="grp">${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `<div class="row2"><span class="ri" style="--c:var(--gold)">${ic('cube')}</span><span class="rt">${esc(x.title)}<small>${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}</small></span>${x.empty ? '' : `<button class="rv" data-fly="${x.lat},${x.lon}">${ic('chevr', 'sm')}</button>`}</div>`).join('')}</div>
<div class="sec-h"><h2>Following</h2><span>alerts</span></div>
<div class="grp">${Object.entries(YOU.follows).map(([id, on]) => { const s = GE.SIT.byId[id]; if (!s) return ''; return `<div class="row2"><span class="ri" style="--c:${GE.FAM[s.fam].color}">${ic(GE.FAM[s.fam].icon)}</span><span class="rt">${esc(s.title.split(' — ')[0])}<small>${esc(s.place)}</small></span><button class="sw2 ${on ? 'on' : ''}" data-f="${id}" role="switch" aria-checked="${on}" aria-label="Follow"></button></div>`; }).join('')}</div>
<div class="sec-h"><h2>Alert radius</h2><span class="num" id="radV">${YOU.radius} km</span></div>
<div class="grp radius"><svg viewBox="0 0 300 120" id="radSvg"></svg><input type="range" min="10" max="2000" step="10" value="${YOU.radius}" id="rad" aria-label="Alert radius"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text3)"><span>10 km</span><span id="radN" class="num"></span><span>2,000 km</span></div></div>
<div class="sec-h"><h2>Rendering</h2><span>adaptive</span></div>
<div class="grp"><div class="row2"><span class="ri" style="--c:var(--sky)">${ic('gauge')}</span><span class="rt">Resolution scale<small>Drops when frames take > 20 ms for 2 s</small></span><span class="rv num" id="qScale"></span></div>
<div class="row2"><span class="ri" style="--c:var(--sky)">${ic('cpu')}</span><span class="rt">Frame time<small>Post chain: atmosphere → FXAA → half-res bloom → grade</small></span><span class="rv num" id="qFps"></span></div>
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('sparkles')}</span><span class="rt">Bloom</span><button class="sw2 ${W.quality.bloom ? 'on' : ''}" data-q="bloom" role="switch" aria-label="Bloom"></button></div>
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('frame')}</span><span class="rt">Film grain</span><button class="sw2 ${W.quality.grain ? 'on' : ''}" data-q="grain" role="switch" aria-label="Grain"></button></div>
<div class="row2"><span class="ri" style="--c:var(--text3)">${ic('gyro')}</span><span class="rt">Reduced motion<small>Follows your system setting</small></span><span class="rv">${prefersReduced() ? 'On' : 'Off'}</span></div></div>
<div class="sec-h"><h2>About</h2><span>vision mockup</span></div>
<div class="grp credits"><p style="margin:0 0 10px"><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></p>
<p style="margin:0 0 8px"><b>God’s Eye</b> — 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.</p>
<p style="margin:0 0 8px"><b>Imagery:</b> 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.</p>
<p style="margin:0 0 8px"><b>Real crypto:</b> SHA-256 (WebCrypto) over every rendered capture and ledger entry; ECDSA P-256 signatures with a non-extractable key generated on this device.</p>
<p style="margin:0"><b>Renderer:</b> 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.</p></div></div>`;
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 = `<rect width="300" height="120" fill="#070A10"/>`;
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 += `<circle cx="${x}" cy="${y}" r="1.6" fill="rgba(255,210,140,.5)"/>`; }
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 += `<circle cx="${x}" cy="${y}" r="3" fill="${GE.FAM[s0.fam].color}"/>`; }
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 += `<ellipse cx="150" cy="60" rx="${r * sx}" ry="${r * sy}" fill="rgba(124,196,255,.08)" stroke="#7CC4FF" stroke-width="1.2" stroke-dasharray="4 3"/><circle cx="150" cy="60" r="3.5" fill="#fff"/><text x="156" y="57" font-size="10" fill="rgba(255,255,255,.75)" font-family="var(--font)">${esc(me.name)}</text>`;
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 = `<div class="panel glass strong"><div class="grab" style="height:22px;display:grid;place-items:center"><span style="width:38px;height:5px;border-radius:3px;background:rgba(255,255,255,.3)"></span></div>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px"><div style="width:92px;height:69px;border-radius:12px;background:url(${c.thumb}) center/cover;flex:none;position:relative"><svg class="seal" style="position:absolute;right:-6px;top:-6px"><use href="#i-seal"/></svg></div><div><div style="font-weight:700;font-size:17px">Verify capture</div><div style="color:var(--text2);font-size:13px">${esc(c.place)} · <span class="num">${fmt.utc(c.at)}</span></div></div><button class="circ" id="insX" style="margin-left:auto;background:rgba(255,255,255,.08)">${ic('x')}</button></div>
<div class="steps" id="vsteps">${['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) => `<div class="step" data-k="${k}"><span class="ck">${ic('check', 'xs')}</span><div><b>${t}</b><div class="h" data-h></div></div></div>`).join('')}</div>
<div style="margin-top:10px;font-size:11px;color:var(--text3);font-family:var(--mono);word-break:break-all">pubkey x ${c.jwk?.x || '—'}<br>pubkey y ${c.jwk?.y || '—'}</div>
<p style="font-size:11.5px;color:var(--text3);margin:10px 0 0">In a native build the key lives in the Secure Enclave / StrongBox and the bundle is written as a C2PA manifest.</p></div>`;
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)}…`);
};
+103
View File
@@ -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', '<div style="position:fixed;inset:0;display:grid;place-items:center;color:#fff;font:15px system-ui;padding:24px;text-align:center;background:#05070B">This vision needs WebGL2. Try a recent browser on a phone, tablet or desktop.</div>'); 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); });