mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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:
56 files changed
+23645
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/** Small DOM, formatting, motion and haptic helpers shared by the shell. */
|
||||
|
||||
export const $ = (sel, root = document) => root.querySelector(sel);
|
||||
export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
const ESC = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
/** Escape untrusted text for HTML interpolation. */
|
||||
export const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
|
||||
|
||||
/** Build one element from an HTML string. */
|
||||
export function h(html) {
|
||||
const t = document.createElement('template');
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstElementChild;
|
||||
}
|
||||
|
||||
export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
export const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
/** "2 min ago", "just now", "3 h ago". */
|
||||
export function ago(ts, now = Date.now()) {
|
||||
if (!ts) return '';
|
||||
const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
|
||||
const s = Math.max(0, Math.round((now - t) / 1000));
|
||||
if (s < 45) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const hrs = Math.round(m / 60);
|
||||
if (hrs < 48) return `${hrs} h ago`;
|
||||
return `${Math.round(hrs / 24)} d ago`;
|
||||
}
|
||||
|
||||
export function fmtNum(v, digits = 0) {
|
||||
if (v == null || v === '') return '—';
|
||||
if (typeof v !== 'number') return String(v);
|
||||
if (Math.abs(v) >= 10000)
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(v);
|
||||
return v.toLocaleString(undefined, {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtCoord(lat, lon, d = 4) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return '—';
|
||||
const ns = lat >= 0 ? 'N' : 'S';
|
||||
const ew = lon >= 0 ? 'E' : 'W';
|
||||
return `${Math.abs(lat).toFixed(d)}° ${ns} ${Math.abs(lon).toFixed(d)}° ${ew}`;
|
||||
}
|
||||
|
||||
export function fmtDist(m) {
|
||||
if (!Number.isFinite(m)) return '—';
|
||||
if (m < 1000) return `${Math.round(m)} m`;
|
||||
if (m < 100000) return `${(m / 1000).toFixed(1)} km`;
|
||||
return `${Math.round(m / 1000).toLocaleString()} km`;
|
||||
}
|
||||
|
||||
export function fmtAlt(m) {
|
||||
if (!Number.isFinite(m)) return '—';
|
||||
if (m > 100000) return `${Math.round(m / 1000).toLocaleString()} km`;
|
||||
const ft = m * 3.28084;
|
||||
return `${Math.round(ft / 100) * 100 >= 1000 ? (Math.round(ft / 100) * 100).toLocaleString() : Math.round(ft)} ft`;
|
||||
}
|
||||
|
||||
/* ---------- Settings (per-viewer conveniences only) ---------- */
|
||||
|
||||
const SETTINGS_KEY = 'gev.mobile.settings.v1';
|
||||
const defaults = {
|
||||
quality: 'auto',
|
||||
reduceMotion: 'system',
|
||||
alertRadiusKm: 250,
|
||||
haptics: true,
|
||||
followed: [],
|
||||
lens: 'cinematic',
|
||||
};
|
||||
|
||||
let settings = { ...defaults };
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) settings = { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
/* storage unavailable: defaults */
|
||||
}
|
||||
|
||||
const subs = new Set();
|
||||
export const store = {
|
||||
get: (k) => settings[k],
|
||||
set(k, v) {
|
||||
settings[k] = v;
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
subs.forEach((fn) => fn(k, v));
|
||||
},
|
||||
on(fn) {
|
||||
subs.add(fn);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
isFollowed: (id) => settings.followed.includes(id),
|
||||
toggleFollow(id) {
|
||||
const list = settings.followed.includes(id)
|
||||
? settings.followed.filter((x) => x !== id)
|
||||
: [id, ...settings.followed];
|
||||
store.set('followed', list);
|
||||
return list.includes(id);
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- Motion ---------- */
|
||||
|
||||
const rmQuery = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
export function reducedMotion() {
|
||||
const pref = settings.reduceMotion;
|
||||
if (pref === 'on') return true;
|
||||
if (pref === 'off') return false;
|
||||
return rmQuery.matches;
|
||||
}
|
||||
|
||||
export function applyMotionPref() {
|
||||
const pref = settings.reduceMotion;
|
||||
if (pref === 'system') delete document.documentElement.dataset.reduceMotion;
|
||||
else document.documentElement.dataset.reduceMotion = pref;
|
||||
}
|
||||
|
||||
/** Spring easing string for WAAPI (falls back to cubic-bezier). */
|
||||
export const SPRING = (() => {
|
||||
try {
|
||||
if (CSS.supports('transition-timing-function', 'linear(0, 1)'))
|
||||
return 'linear(0, 0.009, 0.035 2.1%, 0.141 4.4%, 0.723 12.9%, 0.938 16.7%, 1.017 19.8%, 1.067, 1.089 25.9%, 1.085 29.1%, 1.05 35.4%, 1.016 42.4%, 0.999 51.8%, 0.996 58.3%, 1)';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'cubic-bezier(0.32, 1.25, 0.4, 1)';
|
||||
})();
|
||||
|
||||
export const SPRING_SOFT = (() => {
|
||||
try {
|
||||
if (CSS.supports('transition-timing-function', 'linear(0, 1)'))
|
||||
return 'linear(0, 0.06 3%, 0.25 7.5%, 0.6 15%, 0.84 22%, 0.96 30%, 1.012 38%, 1.02 46%, 1.006 60%, 1)';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'cubic-bezier(0.25, 1.1, 0.4, 1)';
|
||||
})();
|
||||
|
||||
export const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
export const nextFrame = () =>
|
||||
new Promise((r) => requestAnimationFrame(() => r()));
|
||||
|
||||
/* ---------- Haptics ---------- */
|
||||
|
||||
const PATTERNS = {
|
||||
tick: 5,
|
||||
light: 8,
|
||||
medium: 14,
|
||||
success: [10, 40, 18],
|
||||
seal: [12, 60, 12, 60, 28],
|
||||
warn: [20, 50, 20],
|
||||
};
|
||||
export function haptic(kind = 'tick') {
|
||||
if (!settings.haptics) return;
|
||||
try {
|
||||
if (navigator.vibrate) navigator.vibrate(PATTERNS[kind] ?? 5);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
|
||||
let toastTimer = 0;
|
||||
export function toast(text, { icon: iconHtml = '' } = {}) {
|
||||
let el = document.getElementById('toast');
|
||||
if (!el) {
|
||||
el = h(
|
||||
'<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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 & 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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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]),
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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())),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 |
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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-->
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 });
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -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); };
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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) }; };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always
|
||||
wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with
|
||||
uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */
|
||||
const SH = (GE.sheet = { id: null, detent: 'medium' });
|
||||
const sheetEl = () => $('#sheet');
|
||||
SH.heights = function () {
|
||||
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
|
||||
return { peek: 112 + sab, medium: Math.round(H * 0.45), large: Math.round(H * 0.92), H };
|
||||
};
|
||||
SH.setDetent = function (d, animate = true) {
|
||||
SH.detent = d; const el = sheetEl(); if (APP.isWide()) { el.classList.add('wopen'); return; }
|
||||
const hs = SH.heights(); const full = el.offsetHeight || hs.H; const vis = hs[d];
|
||||
el.classList.toggle('drag', !animate);
|
||||
el.style.transform = `translateY(${Math.max(0, full - vis)}px)`;
|
||||
const sc = $('#sheetC'); sc.style.overflowY = d === 'large' ? 'auto' : 'hidden'; sc.style.touchAction = d === 'large' ? 'pan-y' : 'none';
|
||||
if (d !== 'large') sc.scrollTop = 0;
|
||||
SH.targetShift = d === 'large' ? 0.75 : clamp(vis / hs.H, 0, 0.8);
|
||||
};
|
||||
SH.open = function (id, o = {}) {
|
||||
const s = GE.SIT.byId[id]; if (!s) return;
|
||||
const W = GE.world;
|
||||
SH.id = id; SH.sit = s;
|
||||
if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); }
|
||||
APP.setMode('situation');
|
||||
// layers & time window
|
||||
SH._savedLayers = SH._savedLayers || W.layers.map((l) => l.on);
|
||||
for (const lid of s.layers || []) if (W.layerById[lid]) W.layerById[lid].on = true;
|
||||
bus.emit('layers', W.layers);
|
||||
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
|
||||
W.auroraK = s.auroraK ?? 0.85;
|
||||
GE.setExtras && GE.setExtras(s.id);
|
||||
// camera
|
||||
const cam = GE.SIT.cameraFor(s);
|
||||
const hs = SH.heights();
|
||||
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
|
||||
SH.targetShift = shiftY;
|
||||
Cam.autoSpin = 0;
|
||||
if (o.morph) { Cam.set({ ...cam, shiftY, shiftX: 0 }); Cam.flyTo({ range: cam.range * 0.82, heading: cam.heading + 0.12 }, { duration: 2.6 }); }
|
||||
else Cam.flyTo({ ...cam, shiftY, shiftX: 0 });
|
||||
W.focus = s.kind === 'recon' ? null : { lat: s.lat, lon: s.lon, k: 0.35 };
|
||||
SH.render(s);
|
||||
const el = sheetEl();
|
||||
if (APP.isWide()) { el.classList.add('wopen'); }
|
||||
else { el.classList.add('drag'); el.style.transform = 'translateY(100%)'; requestAnimationFrame(() => requestAnimationFrame(() => SH.setDetent(o.detent || 'medium'))); }
|
||||
if (o.ride && s.ride) setTimeout(() => GE.ride.start({ kind: 'heli', id: 0 }), 650);
|
||||
APP.updateChrome();
|
||||
};
|
||||
SH.close = function (silent) {
|
||||
if (!SH.id) return; const id = SH.id; const W = GE.world;
|
||||
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null);
|
||||
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
|
||||
W.setWindow(null); W.setTime(null); W.auroraK = 0.85;
|
||||
APP.setMode('');
|
||||
if (silent) return;
|
||||
const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`);
|
||||
if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
|
||||
else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); }
|
||||
};
|
||||
|
||||
/* drag */
|
||||
SH.initDrag = function () {
|
||||
const el = sheetEl(); let st = null;
|
||||
const begin = (e) => {
|
||||
if (APP.isWide()) return;
|
||||
const onGrab = e.target.closest('#grab'); const sc = $('#sheetC');
|
||||
if (!onGrab && SH.detent === 'large') return;
|
||||
if (e.target.closest('button, a, input, textarea, .evid, .multi')) return;
|
||||
st = { y: e.clientY, y0: e.clientY, t: performance.now(), base: new DOMMatrix(getComputedStyle(el).transform).m42, moved: 0, id: e.pointerId, samples: [] };
|
||||
el.setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
el.addEventListener('pointerdown', begin);
|
||||
el.addEventListener('pointermove', (e) => {
|
||||
if (!st || e.pointerId !== st.id) return;
|
||||
const dy = e.clientY - st.y0; st.moved = Math.max(st.moved, Math.abs(dy));
|
||||
if (st.moved < 4) return;
|
||||
el.classList.add('drag');
|
||||
const hs = SH.heights(); const full = el.offsetHeight; const minY = full - hs.large, maxY = full - hs.peek + 40;
|
||||
let y = st.base + dy; if (y < minY) y = minY - Math.sqrt(minY - y) * 3; if (y > maxY) y = maxY + Math.sqrt(y - maxY) * 3;
|
||||
el.style.transform = `translateY(${y}px)`;
|
||||
st.samples.push({ t: performance.now(), y: e.clientY }); if (st.samples.length > 6) st.samples.shift();
|
||||
SH.targetShift = clamp((full - y) / hs.H, 0, 0.8);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!st || e.pointerId !== st.id) return;
|
||||
const s0 = st.samples[0], s1 = st.samples[st.samples.length - 1];
|
||||
const v = s0 && s1 && s1.t > s0.t ? (s1.y - s0.y) / (s1.t - s0.t) : 0; // px/ms
|
||||
const hs = SH.heights(); const full = el.offsetHeight;
|
||||
const cur = new DOMMatrix(getComputedStyle(el).transform).m42; const visNow = full - cur + -v * 220;
|
||||
if (st.moved >= 4) {
|
||||
if (visNow < hs.peek * 0.6 && v > 0.6) { SH.close(); st = null; return; }
|
||||
const ds = ['peek', 'medium', 'large']; let best = 'medium', bd = 1e9; for (const d of ds) { const dd = Math.abs(hs[d] - visNow); if (dd < bd) { bd = dd; best = d; } }
|
||||
SH.setDetent(best); haptic(5);
|
||||
} else if (e.target.closest('#grab')) { SH.setDetent(SH.detent === 'peek' ? 'medium' : SH.detent === 'medium' ? 'large' : 'medium'); }
|
||||
st = null;
|
||||
};
|
||||
el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
|
||||
bus.on('globe-touch', () => { if (SH.id && SH.detent !== 'peek' && !APP.isWide()) SH.setDetent('peek'); });
|
||||
$('#back').addEventListener('click', () => { if (APP.mode === 'situation') SH.close(); });
|
||||
$('#sitFollow').addEventListener('click', () => { if (!SH.id) return; APP.following = SH.id === 'cyclone' || !SH.sit.accessory ? 'cyclone' : SH.id; APP.toast(`${ic('bell', 'sm')} Following · alerts within your radius`); haptic(10); });
|
||||
$('#sitShare').addEventListener('click', () => APP.toast(`${ic('link', 'sm')} Link copied · opens at this place & time`));
|
||||
};
|
||||
|
||||
/* ---------- content ---------- */
|
||||
function sparkSVG(sr, w) {
|
||||
const hgt = 46, pad = 3; const d = sr.data; const n = d.length;
|
||||
const band = sr.band || 0;
|
||||
let mn = Math.min(...d) - band, mx = Math.max(...d) + band; if (mx - mn < 1e-6) { mx += 1; mn -= 1; }
|
||||
const X = (i) => pad + (i / (n - 1)) * (w - pad * 2 - 34), Y = (v) => pad + 6 + (1 - (v - mn) / (mx - mn)) * (hgt - pad * 2 - 12);
|
||||
const split = sr.split ?? n - 1;
|
||||
const path = (a, b) => d.slice(a, b + 1).map((v, k) => `${k ? 'L' : 'M'}${X(a + k).toFixed(1)} ${Y(v).toFixed(1)}`).join('');
|
||||
let s = '';
|
||||
if (band) { const up = d.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v + band * (i > split ? 1 + (i - split) * 0.35 : 1)).toFixed(1)}`).join(''); const lo = d.map((v, i) => [X(i), Y(v - band * (i > split ? 1 + (i - split) * 0.35 : 1))]).reverse().map(([x, y]) => `L${x.toFixed(1)} ${y.toFixed(1)}`).join(''); s += `<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 & 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`); }
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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(); });
|
||||
};
|
||||
@@ -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`); });
|
||||
};
|
||||
@@ -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)}…`);
|
||||
};
|
||||
@@ -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); });
|
||||
Reference in new issue
Block a user