feat(mobile): add the untethered vision mockup and document the mobile app

mobile/vision/index.html is a single self-contained file: a hand-written
WebGL2 planet with real sun position, Black Marble lights, atmosphere,
clouds and a mobile post chain; the full feed, card-to-world morph, Ride,
lenses and time ribbon over synthetic traffic; capture with real
WebCrypto seals; and reconstruction threads with a verifiable ledger and
an AI refine composer. All data is illustrative and marked as such.
build.mjs regenerates it offline from src/ and assets/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 06:16:11 +00:00
1 parent c2a0c2e23e
commit 3218b7d427
16 files changed
+198 -128

No files matched your search

+18
View File
@@ -59,6 +59,24 @@ provider registration rejects compositions that cannot fit rather than truncatin
identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply.
## Mobile app prototype — October 18, 2026
`/mobile/` is a phone and foldable shell over the same engine, specified in
[`mobile/DESIGN.md`](../mobile/DESIGN.md). It has five tabs: a situation feed,
the globe, capture, reconstruction threads and the user's own captures.
`mobile/globe/` is a phone-tuned Cesium engine. It blends Esri day imagery
with NASA Black Marble night lights across the terminator, uses Google 3D
tiles below 25 km when keyed, and builds its layers on the existing `/api`
routes and source parsers. Capture signs each bundle on the device with a
non-extractable ECDSA P-256 WebCrypto key held in IndexedDB. A bundle carries
a SHA-256 media hash, a per-device hash chain, and the IMU and GPS trace.
These signatures are browser equivalents of hardware attestation; they are
not hardware attestation. Hypotheses and authors in the reconstruction
threads are labelled example content. `npm run build` now emits
`dist/mobile/index.html` beside the app, while `npm run build:panel` still
builds the app page alone. `mobile/vision/index.html` is a self-contained
concept mockup with illustrative data and no production code.
## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers,
+1 -1
View File
@@ -7,7 +7,7 @@ This folder holds two things:
| Path | What it is | Tethered to production? |
| --- | --- | --- |
| [`mobile/index.html`](index.html) → `/mobile/` | **Prototype.** A working phone/foldable app that sets the design language on top of the real engine: CesiumJS, Esri cloudless imagery, NASA Black Marble night lights, Google Photorealistic 3D Tiles (when keyed), and the app's own live `/api` feeds (ADS-B, military, AIS, TLEs, quakes, FIRMS fires, cyclones, launches, CCTV) plus the bundled Bhote Koshi event pack. Capture is real: camera, IMU, GPS and a WebCrypto signature. | Yes. Runs on the existing dev server (`npm run dev`, open `http://localhost:4173/mobile/`). |
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file in any browser. |
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file straight from disk in any browser (the dev server's CSP blocks its inline script). Rebuild it with `node mobile/vision/build.mjs`. |
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 238 KiB

+89 -63
View File
@@ -294,7 +294,7 @@ input, textarea { font: inherit; color: inherit; }
.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); }
.chat { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 72px); height: 34%; 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; }
@@ -377,7 +377,7 @@ input, textarea { font: inherit; color: inherit; }
.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); display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ws .wsh .tt span.sub { font-size: 11.5px; color: var(--text2); display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
@@ -524,7 +524,10 @@ input, textarea { font: inherit; color: inherit; }
@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); }
.tabwrap { --tabW: var(--paneW); padding-left: 8px; padding-right: 8px; gap: 8px; }
.tab, .tablens { width: 54px; }
.searchbtn { width: 54px; height: 54px; }
.tabbar { height: 58px; } .tablens { height: 50px; } .tab { height: 50px; }
.acc-host { right: auto; width: calc(var(--paneW) - 24px); }
.app[data-tab="globe"] .acc-host { display: flex; }
.wide-only { display: block !important; }
@@ -548,10 +551,12 @@ input, textarea { font: inherit; color: inherit; }
#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; }
.app[data-mode="situation"] .concept-tag { right: 124px; }
.composer { left: 10px; right: 10px; }
}
@media (min-width: 1100px) {
:root { --paneW: 440px; }
.tab, .tablens { width: 66px; } .searchbtn { width: 62px; height: 62px; } .tabbar { height: 62px; } .tablens { height: 54px; } .tab { height: 54px; }
.masonry { grid-template-columns: 1fr 1fr; }
}
/* hinge-aware: vertical fold — panes split exactly at the hinge */
@@ -732,7 +737,7 @@ input, textarea { font: inherit; color: inherit; }
<div class="tag">The window to the world.</div>
</div>
</div>
<script>window.__GE_TEX={"clouds":"data:image/webp;base64,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 truncated
<script>window.__GE_TEX={"clouds":"data:image/webp;base64,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 truncated
<script>
(() => {
'use strict';
@@ -983,6 +988,9 @@ Object.assign(GE, { D2R, R2D, TAU, EARTH_KM, clamp, lerp, smooth, ease, v3, m4,
callsigns and reconstruction claims are illustrative (Concept · illustrative data). */
const NOW = Date.now();
GE.NOW = NOW;
const lastUTC = (h, m) => { const d = new Date(NOW); let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), h, m); if (t > NOW - 3600000) t -= 86400000; return t; };
GE.T_CONCERT = lastUTC(21, 44); GE.T_CLIMB = lastUTC(10, 52);
GE.dayWord = (t, part) => (new Date(t).getUTCDate() === new Date(NOW).getUTCDate() ? 'this ' + part : (part === 'night' ? 'last night' : 'yesterday ' + part)).replace('this night', 'tonight');
const R = rng(20261018);
/* ---------- airports [iata, city, lat, lon, weight] ---------- */
@@ -1406,9 +1414,9 @@ vec3 aurora(vec3 n, float nightF){
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;
float curt = 0.25 + 0.75*fbm(vec2(lonm*14. + uT*0.0006, uT*0.00025 + ml*0.15));
float g = exp(-pow((ml-band)/(0.7+uAurora*0.25), 2.)) * curt * curt * 0.6;
float r = exp(-pow((ml-band-2.5)/2.0, 2.)) * 0.08 * curt;
return (vec3(0.12, 1.0, 0.5)*g + vec3(0.75, 0.15, 0.45)*r*0.45) * nightF * uAurora * 0.75;
}
vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3 emis){
@@ -1428,14 +1436,18 @@ vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3
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);
vec2 fid = floor(p/(B*2.6)); float fh = mix(hash12(fid+7.), 0.5, clamp(fw/(B*2.6)*1.6, 0., 1.));
vec3 field = base * mix(vec3(0.94,1.02,0.9), vec3(1.12,1.06,0.86), fh) * (0.92+0.16*vnoise(p*0.02));
float forest = smoothstep(0.5,0.66, fbm(p*0.0011+3.));
field = mix(field, base*vec3(0.78,0.88,0.74), forest*0.6);
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);
float emin = min(e.x, e.y);
float curb = smoothstep(rw*0.55, rw*0.8, emin) * (1. - smoothstep(rw*0.8, rw*0.98, emin));
float along2 = (e.x < e.y) ? g.y : g.x;
float dotsL = smoothstep(0.75, 0.95, fract(along2*B/28.));
float lamps = mix(curb*(0.25 + dotsL*1.6), 0.07, aa) * urban;
emis = vec3(1.0,0.56,0.22)*lamps*night*1.0;
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);
@@ -1530,7 +1542,7 @@ void main(){
}
// 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);
vec3 emis = lightCol * lights * 3.2 * nightF * (1. - uCity.x*0.995);
emis += pow(nightTex, vec3(2.2)) * vec3(0.5,0.6,1.0) * 0.12 * nightF;
emis += emisD;
emis *= (1.-cl*0.82);
@@ -1858,9 +1870,12 @@ void main(){
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.);
float frac = 0.12 + 0.5*uNight;
float lit = step(1. - frac, 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;
float fwv = length(fwidth(vUV));
float pat = mix(lit * (0.9 + 0.6*r), frac * 0.36 * 0.75 * (0.15 + 1.6*fract(h*13.7)*fract(h*13.7)), smoothstep(0.9, 2.6, fwv));
emis += wc * pat * uNight * 2.2;
base = mix(base, base*0.45 + uSkyCol*0.08, win*0.8);
spec = win*(1.-uNight)*0.9 + step(0.68, h)*0.5;
} else base *= 0.82;
@@ -1908,6 +1923,10 @@ void main(){
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 if (type == 7) { // floodlit launch structure
base = vec3(0.75, 0.76, 0.78) * (0.6 + 0.4*vAttr.y);
emis = vec3(1.0, 0.93, 0.82) * uNight * (0.35 + 0.25*smoothstep(0., 60., vUV.y));
heat = 0.6;
} 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));
@@ -2265,7 +2284,7 @@ W.buildStatic = function () {
{ 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')); }
addSet('fires', makeSpriteSet('fires', a)); addSet('smoke', 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
@@ -2455,7 +2474,7 @@ W.updateDynamic = function () {
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); }
if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - GE.T_CLIMB) / (2.23 * 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
@@ -2539,7 +2558,8 @@ W.project = function (view, p) { // world unit coords → css px {x,y,z,vis}
/* ---------- render ---------- */
W.quality = { scale: 1, steps: 12, bloom: true, grain: true };
const LOD_MIN = { radar: 0.12 };
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.on = (id) => id === 'smoke' ? !!W.layerById.fires?.on : !!W.layerById[id]?.on;
const LOD = { smoke: 0.7, 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);
@@ -2624,7 +2644,7 @@ W.render = function (view, chain, outFb, opts = {}) {
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);
const FP = PR.flight; common(FP, lerp(1, 0.42, smooth(0.35, 1.8, range))); 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);
}
@@ -2788,7 +2808,7 @@ function genCity(sc, M, R0) {
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);
h = clamp(h, 6, sc.maxH * (sc.tower ? 1.0 : 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]);
@@ -2796,7 +2816,7 @@ function genCity(sc, M, R0) {
}
}
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);
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); M.box(twr[0], twr[1], T.w * 0.76, T.w * 0.76, 5, sc.rot, [5, 0.5, T.h, 0], T.h * 0.9);
}
}
function genStadium(sc, M) {
@@ -2833,8 +2853,8 @@ function genAirfield(sc, M) {
}
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(-14, 0, 12, 12, 88, 0, [7, 0.35, 88, 0]);
M.box(4, 0, 4.2, 4.2, 70, 0, [7, 1.0, 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]);
}
@@ -3266,7 +3286,7 @@ add({
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.2705, lon: -85.7075, range: 0.0011, heading: -0.65, tilt: 1.15 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2),
camera: { lat: 31.2745, lon: -85.7105, range: 0.00062, heading: -0.65, tilt: 0.9 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2), labels3d: [[31.276, -85.713, 'Cairns AAF', 0.05], [31.36, -85.63, 'Stagefield A', 0.05]],
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']] },
@@ -3274,7 +3294,7 @@ add({
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)',
camera: { lat: 28.5585, lon: -80.5825, range: 0.00045, heading: 0.85, tilt: 1.28 }, labels3d: [[28.5618, -80.5772, 'SLC-40 · vehicle on pad', 0.11]], thumbCam: { lat: 28.25, lon: -80.95, range: 0.028, heading: 0.75, tilt: 1.0 }, 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']] },
@@ -3289,16 +3309,16 @@ add({
});
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 },
title: 'Stadium encore in 4D — from 2,311 crowd captures', place: 'Wembley, London · ' + GE.dayWord(GE.T_CONCERT, 'night'), lat: 51.556, lon: -0.2796,
camera: { lat: 51.5553, lon: -0.2806, range: 0.00026, heading: 0.55, tilt: 1.0 }, time: { at: GE.T_CONCERT, start: GE.T_CONCERT - 3600000, end: GE.T_CONCERT + 1800000 },
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 },
title: 'Free-climber on a Midtown tower — reconstructed', place: 'Midtown Manhattan · ' + GE.dayWord(GE.T_CLIMB, 'morning'), lat: 40.7553, lon: -73.9838,
camera: { lat: 40.7532, lon: -73.9865, range: 0.00055, heading: 0.62, tilt: 1.12 }, time: { at: GE.T_CLIMB + 1.2 * 3600000, start: GE.T_CLIMB - 1800000, end: GE.T_CLIMB + 3 * 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']] },
@@ -3370,7 +3390,7 @@ SIT.sections = [
// 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 === '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 - 4, lon: p.lon - 3, range: 0.3, heading: 0.3, tilt: 0.75 }; }
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;
};
@@ -3720,7 +3740,7 @@ SH.open = function (id, o = {}) {
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);
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d);
// camera
const cam = GE.SIT.cameraFor(s);
const hs = SH.heights();
@@ -3740,7 +3760,7 @@ SH.open = function (id, o = {}) {
SH.close = function (silent, keepTime) {
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);
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null);
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
if (!keepTime) { W.setWindow(null); W.setTime(null); } W.auroraK = 0.85;
APP.setMode('');
@@ -4309,7 +4329,7 @@ CAP.frame = function (view) {
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;
const seen = new Set(); let n = 0; const used = [];
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;
@@ -4318,7 +4338,8 @@ CAP.frame = function (view) {
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)`;
const flip = s.x > view.w - 210; el.querySelector('.callout').style.transform = flip ? `translate(${s.x - 24}px, ${s.y}px) translate(-100%, -50%)` : `translate(${s.x + 24}px, ${s.y}px) translate(0, -50%)`;
let cy = s.y; for (const u of used) if (Math.abs(u - cy) < 44) cy = u + 44; used.push(cy);
const flip = s.x > view.w - 210; el.querySelector('.callout').style.transform = flip ? `translate(${s.x - 24}px, ${cy}px) translate(-100%, -50%)` : `translate(${s.x + 24}px, ${cy}px) translate(0, -50%)`;
}
host.querySelectorAll('[data-k]').forEach((e) => { if (!seen.has(e.dataset.k)) e.remove(); });
// witness sensors
@@ -4398,7 +4419,8 @@ 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 },
cam: { lat: 28.2, lon: 85.35, range: 0.0046, heading: 0.32, tilt: 0.98 },
labels: [[28.1600, 85.3395, 'Syaphrubesi'], [28.2560, 85.3690, 'Timure'], [28.2802, 85.3797, 'Rasuwagadhi bridge']],
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%'] },
@@ -4418,24 +4440,25 @@ const THREADS = {
{ 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 } },
{ 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.268, lon: 85.37, range: 0.0032, heading: 3.55, tilt: 0.98 } },
{ at: KT + 20 * MIN, t: '08:05', text: 'The crest reaches Rasuwagadhi and the border bridge.', cites: ['H2', 'O7'], cam: { lat: 28.276, lon: 85.376, range: 0.0026, heading: 2.6, tilt: 0.95 } },
{ 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.252, lon: 85.368, range: 0.0027, heading: 3.4, tilt: 0.96 } },
{ at: KT + 61 * MIN, t: '08:46', text: 'Debris piles against the Syaphrubesi bridge deck.', cites: ['O6', 'H4'], cam: { lat: 28.16, lon: 85.338, range: 0.0027, heading: 0.25, tilt: 0.96 } },
{ 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.155, lon: 85.332, range: 0.0032, heading: 0.9, tilt: 0.98 } },
{ at: KT + 140 * MIN, t: '10:05', text: 'First rescue sortie climbs the valley.', cites: ['O8'], cam: { lat: 28.2, lon: 85.35, range: 0.0045, heading: 0.3, tilt: 0.98 } },
],
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'],
id: 'concert', sit: 'concert', title: 'Stadium encore in 4D', place: 'Wembley, London', date: GE.dayWord(GE.T_CONCERT, 'night'),
window: { start: GE.T_CONCERT - 1 * HR_, end: GE.T_CONCERT + 0.5 * HR_ }, people: ['crowd', 'sky', 'kora', 'geo'],
cam: { lat: 51.5546, lon: -0.2822, range: 0.00042, heading: 0.6, tilt: 1.1 },
labels: [[51.556, -0.2803, 'Stage · encore', 0.05]],
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: 'O1', i: 'seal', f: 'eyes', t: '2,311 signed phone captures from 86 seat blocks', src: 'Signed bundles (illustrative)', at: GE.T_CONCERT, 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: GE.T_CONCERT + 0.2 * HR_, meta: ['common track'] },
{ id: 'O3', i: 'video', f: 'eyes', t: 'Venue public camera, concourse north', src: 'Public feed (illustrative)', at: GE.T_CONCERT, 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: [
@@ -4443,21 +4466,22 @@ const THREADS = {
{ 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 } },
{ at: GE.T_CONCERT - 3 * 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: GE.T_CONCERT, 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: GE.T_CONCERT + 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 },
id: 'climb', sit: 'climb', title: 'Free-climber on a Midtown tower', place: 'Midtown Manhattan (fictional tower)', date: GE.dayWord(GE.T_CLIMB, 'morning'),
window: { start: GE.T_CLIMB - 0.5 * HR_, end: GE.T_CLIMB + 3 * HR_ }, people: ['news', 'sky', 'kora', 'geo', 'ais'],
cam: { lat: 40.7532, lon: -73.9865, range: 0.00055, heading: 0.62, tilt: 1.12 },
labels: [[40.7553, -73.9838, 'Meridian Tower (fictional)', 0.43]],
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: 'O1', i: 'helicopter', f: 'sky', t: 'ADS-B tracks: 3 news helicopters orbiting the tower', src: 'ADS-B (illustrative callsigns)', at: GE.T_CLIMB + 1.2 * HR_, meta: ['1 Hz', '2 h 14 m'] },
{ id: 'O2', i: 'video', f: 'eyes', t: 'Broadcast frames, time-coded', src: 'Public broadcast (illustrative)', at: GE.T_CLIMB + 1.2 * HR_, meta: ['29.97 fps'] },
{ id: 'O3', i: 'seal', f: 'eyes', t: '41 signed street-level clips', src: 'Signed bundles (illustrative)', at: GE.T_CLIMB + 1.2 * 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: [
@@ -4465,9 +4489,9 @@ const THREADS = {
{ 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 } },
{ at: GE.T_CLIMB, 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: GE.T_CLIMB + 63 * MIN, 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: GE.T_CLIMB + 134 * MIN, 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',
@@ -4479,6 +4503,7 @@ THR.data = THREADS;
const EXTRAS = {};
GE.extrasFor = function (id) {
if (EXTRAS[id] !== undefined) return EXTRAS[id];
if (id === 'launch') { const sp = [], ln = []; const pad = [28.5618, -80.5772]; pushSpr(sp, geo.vec(pad[0], pad[1], 0.04), [1, 0.86, 0.6, 0.9], 70, 0, -10, -1e9, 1e9, 0, 0, 0.9); for (const [dx, dy] of [[90, 70], [-80, 60], [70, -80], [-90, -70]]) { const [la, lo] = geo.offset(pad[0], pad[1], dx, dy); pushSpr(sp, geo.vec(la, lo, 0.02), [1, 0.9, 0.7, 0.8], 26, 0, -10, -1e9, 1e9, 0, 0, 0.6); } return (EXTRAS[id] = { sprites: [makeSpriteSet('xl', sp)], lines: null }); }
const T = THREADS[id] || (GE.SIT.byId[id]?.thread && THREADS[GE.SIT.byId[id].thread]);
if (!T) return (EXTRAS[id] = null);
const sp = [], ln = [];
@@ -4509,6 +4534,7 @@ GE.extrasFor = function (id) {
const set = { sprites: sp.length ? [makeSpriteSet('x' + id, sp)] : null, lines: ln.length ? [makeLineSet(ln)] : null };
return (EXTRAS[id] = set);
};
GE.setLabels3d = function (L) { GE.extraLabels.clear(); for (const [la, lo, t, alt] of L || []) { const z = GE.scenes.byId.koshi.height && Math.abs(la - 28.2) < 0.2 && Math.abs(lo - 85.35) < 0.2 ? GE.scenes.groundAt(la, lo) / 1000 + 0.12 : alt || 0.05; GE.extraLabels.set('x:' + t, { lat: la, lon: lo, alt: z, html: `<i style="--c:var(--gold)"></i>${esc(t)}`, w: t.length * 6.6 + 32, cls: 'glass' }); } };
GE.setExtras = function (id) { GE.world.activeExtras = id ? GE.extrasFor(id) : null; GE.world.activeExtrasId = id; };
/* ---------- ledger (real SHA-256 hash chain) ---------- */
@@ -4560,7 +4586,7 @@ THR.openWs = function (id) {
$('#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);
GE.setExtras(T.id); GE.setLabels3d(T.labels);
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 });
@@ -4570,12 +4596,12 @@ THR.openWs = function (id) {
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('');
W.setWindow(null); W.setTime(null); GE.setExtras(null); GE.setLabels3d(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>
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 class="sub">${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());
@@ -4790,7 +4816,7 @@ function frame(now) {
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 (APP.intro || !(Cam.anim || Cam.interacting || GE.thumbs._busyUntil > now)) 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);
+2 -2
View File
@@ -13,8 +13,8 @@ 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 })],
['earth-blue-marble.jpg', 'day.webp', (s) => s.resize(4096, 2048).webp({ quality: 84, smartSubsample: true, effort: 6 })],
['BlackMarble_2016_3km.jpg', 'night.webp', (s) => s.resize(4096, 2048, { kernel: 'lanczos3' }).webp({ quality: 80, 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 })],
+8 -3
View File
@@ -289,7 +289,7 @@ input, textarea { font: inherit; color: inherit; }
.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); }
.chat { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 72px); height: 34%; 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; }
@@ -372,7 +372,7 @@ input, textarea { font: inherit; color: inherit; }
.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); display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ws .wsh .tt span.sub { font-size: 11.5px; color: var(--text2); display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
@@ -519,7 +519,10 @@ input, textarea { font: inherit; color: inherit; }
@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); }
.tabwrap { --tabW: var(--paneW); padding-left: 8px; padding-right: 8px; gap: 8px; }
.tab, .tablens { width: 54px; }
.searchbtn { width: 54px; height: 54px; }
.tabbar { height: 58px; } .tablens { height: 50px; } .tab { height: 50px; }
.acc-host { right: auto; width: calc(var(--paneW) - 24px); }
.app[data-tab="globe"] .acc-host { display: flex; }
.wide-only { display: block !important; }
@@ -543,10 +546,12 @@ input, textarea { font: inherit; color: inherit; }
#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; }
.app[data-mode="situation"] .concept-tag { right: 124px; }
.composer { left: 10px; right: 10px; }
}
@media (min-width: 1100px) {
:root { --paneW: 440px; }
.tab, .tablens { width: 66px; } .searchbtn { width: 62px; height: 62px; } .tabbar { height: 62px; } .tablens { height: 54px; } .tab { height: 54px; }
.masonry { grid-template-columns: 1fr 1fr; }
}
/* hinge-aware: vertical fold — panes split exactly at the hinge */
+3
View File
@@ -2,6 +2,9 @@
callsigns and reconstruction claims are illustrative (Concept · illustrative data). */
const NOW = Date.now();
GE.NOW = NOW;
const lastUTC = (h, m) => { const d = new Date(NOW); let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), h, m); if (t > NOW - 3600000) t -= 86400000; return t; };
GE.T_CONCERT = lastUTC(21, 44); GE.T_CLIMB = lastUTC(10, 52);
GE.dayWord = (t, part) => (new Date(t).getUTCDate() === new Date(NOW).getUTCDate() ? 'this ' + part : (part === 'night' ? 'last night' : 'yesterday ' + part)).replace('this night', 'tonight');
const R = rng(20261018);
/* ---------- airports [iata, city, lat, lon, weight] ---------- */
+23 -12
View File
@@ -142,9 +142,9 @@ vec3 aurora(vec3 n, float nightF){
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;
float curt = 0.25 + 0.75*fbm(vec2(lonm*14. + uT*0.0006, uT*0.00025 + ml*0.15));
float g = exp(-pow((ml-band)/(0.7+uAurora*0.25), 2.)) * curt * curt * 0.6;
float r = exp(-pow((ml-band-2.5)/2.0, 2.)) * 0.08 * curt;
return (vec3(0.12, 1.0, 0.5)*g + vec3(0.75, 0.15, 0.45)*r*0.45) * nightF * uAurora * 0.75;
}
vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3 emis){
@@ -164,14 +164,18 @@ vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3
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);
vec2 fid = floor(p/(B*2.6)); float fh = mix(hash12(fid+7.), 0.5, clamp(fw/(B*2.6)*1.6, 0., 1.));
vec3 field = base * mix(vec3(0.94,1.02,0.9), vec3(1.12,1.06,0.86), fh) * (0.92+0.16*vnoise(p*0.02));
float forest = smoothstep(0.5,0.66, fbm(p*0.0011+3.));
field = mix(field, base*vec3(0.78,0.88,0.74), forest*0.6);
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);
float emin = min(e.x, e.y);
float curb = smoothstep(rw*0.55, rw*0.8, emin) * (1. - smoothstep(rw*0.8, rw*0.98, emin));
float along2 = (e.x < e.y) ? g.y : g.x;
float dotsL = smoothstep(0.75, 0.95, fract(along2*B/28.));
float lamps = mix(curb*(0.25 + dotsL*1.6), 0.07, aa) * urban;
emis = vec3(1.0,0.56,0.22)*lamps*night*1.0;
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);
@@ -266,7 +270,7 @@ void main(){
}
// 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);
vec3 emis = lightCol * lights * 3.2 * nightF * (1. - uCity.x*0.995);
emis += pow(nightTex, vec3(2.2)) * vec3(0.5,0.6,1.0) * 0.12 * nightF;
emis += emisD;
emis *= (1.-cl*0.82);
@@ -594,9 +598,12 @@ void main(){
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.);
float frac = 0.12 + 0.5*uNight;
float lit = step(1. - frac, 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;
float fwv = length(fwidth(vUV));
float pat = mix(lit * (0.9 + 0.6*r), frac * 0.36 * 0.75 * (0.15 + 1.6*fract(h*13.7)*fract(h*13.7)), smoothstep(0.9, 2.6, fwv));
emis += wc * pat * uNight * 2.2;
base = mix(base, base*0.45 + uSkyCol*0.08, win*0.8);
spec = win*(1.-uNight)*0.9 + step(0.68, h)*0.5;
} else base *= 0.82;
@@ -644,6 +651,10 @@ void main(){
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 if (type == 7) { // floodlit launch structure
base = vec3(0.75, 0.76, 0.78) * (0.6 + 0.4*vAttr.y);
emis = vec3(1.0, 0.93, 0.82) * uNight * (0.35 + 0.25*smoothstep(0., 60., vUV.y));
heat = 0.6;
} 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));
+5 -4
View File
@@ -129,7 +129,7 @@ W.buildStatic = function () {
{ 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')); }
addSet('fires', makeSpriteSet('fires', a)); addSet('smoke', 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
@@ -319,7 +319,7 @@ W.updateDynamic = function () {
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); }
if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - GE.T_CLIMB) / (2.23 * 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
@@ -403,7 +403,8 @@ W.project = function (view, p) { // world unit coords → css px {x,y,z,vis}
/* ---------- render ---------- */
W.quality = { scale: 1, steps: 12, bloom: true, grain: true };
const LOD_MIN = { radar: 0.12 };
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.on = (id) => id === 'smoke' ? !!W.layerById.fires?.on : !!W.layerById[id]?.on;
const LOD = { smoke: 0.7, 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);
@@ -488,7 +489,7 @@ W.render = function (view, chain, outFb, opts = {}) {
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);
const FP = PR.flight; common(FP, lerp(1, 0.42, smooth(0.35, 1.8, range))); 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);
}
+4 -4
View File
@@ -71,7 +71,7 @@ function genCity(sc, M, R0) {
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);
h = clamp(h, 6, sc.maxH * (sc.tower ? 1.0 : 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]);
@@ -79,7 +79,7 @@ function genCity(sc, M, R0) {
}
}
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);
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); M.box(twr[0], twr[1], T.w * 0.76, T.w * 0.76, 5, sc.rot, [5, 0.5, T.h, 0], T.h * 0.9);
}
}
function genStadium(sc, M) {
@@ -116,8 +116,8 @@ function genAirfield(sc, M) {
}
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(-14, 0, 12, 12, 88, 0, [7, 0.35, 88, 0]);
M.box(4, 0, 4.2, 4.2, 70, 0, [7, 1.0, 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]);
}
+7 -7
View File
@@ -116,7 +116,7 @@ add({
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.2705, lon: -85.7075, range: 0.0011, heading: -0.65, tilt: 1.15 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2),
camera: { lat: 31.2745, lon: -85.7105, range: 0.00062, heading: -0.65, tilt: 0.9 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2), labels3d: [[31.276, -85.713, 'Cairns AAF', 0.05], [31.36, -85.63, 'Stagefield A', 0.05]],
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']] },
@@ -124,7 +124,7 @@ add({
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)',
camera: { lat: 28.5585, lon: -80.5825, range: 0.00045, heading: 0.85, tilt: 1.28 }, labels3d: [[28.5618, -80.5772, 'SLC-40 · vehicle on pad', 0.11]], thumbCam: { lat: 28.25, lon: -80.95, range: 0.028, heading: 0.75, tilt: 1.0 }, 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']] },
@@ -139,16 +139,16 @@ add({
});
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 },
title: 'Stadium encore in 4D — from 2,311 crowd captures', place: 'Wembley, London · ' + GE.dayWord(GE.T_CONCERT, 'night'), lat: 51.556, lon: -0.2796,
camera: { lat: 51.5553, lon: -0.2806, range: 0.00026, heading: 0.55, tilt: 1.0 }, time: { at: GE.T_CONCERT, start: GE.T_CONCERT - 3600000, end: GE.T_CONCERT + 1800000 },
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 },
title: 'Free-climber on a Midtown tower — reconstructed', place: 'Midtown Manhattan · ' + GE.dayWord(GE.T_CLIMB, 'morning'), lat: 40.7553, lon: -73.9838,
camera: { lat: 40.7532, lon: -73.9865, range: 0.00055, heading: 0.62, tilt: 1.12 }, time: { at: GE.T_CLIMB + 1.2 * 3600000, start: GE.T_CLIMB - 1800000, end: GE.T_CLIMB + 3 * 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']] },
@@ -220,7 +220,7 @@ SIT.sections = [
// 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 === '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 - 4, lon: p.lon - 3, range: 0.3, heading: 0.3, tilt: 0.75 }; }
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;
};
+2 -2
View File
@@ -28,7 +28,7 @@ SH.open = function (id, o = {}) {
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);
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d);
// camera
const cam = GE.SIT.cameraFor(s);
const hs = SH.heights();
@@ -48,7 +48,7 @@ SH.open = function (id, o = {}) {
SH.close = function (silent, keepTime) {
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);
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null);
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
if (!keepTime) { W.setWindow(null); W.setTime(null); } W.auroraK = 0.85;
APP.setMode('');
+3 -2
View File
@@ -101,7 +101,7 @@ CAP.frame = function (view) {
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;
const seen = new Set(); let n = 0; const used = [];
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;
@@ -110,7 +110,8 @@ CAP.frame = function (view) {
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)`;
const flip = s.x > view.w - 210; el.querySelector('.callout').style.transform = flip ? `translate(${s.x - 24}px, ${s.y}px) translate(-100%, -50%)` : `translate(${s.x + 24}px, ${s.y}px) translate(0, -50%)`;
let cy = s.y; for (const u of used) if (Math.abs(u - cy) < 44) cy = u + 44; used.push(cy);
const flip = s.x > view.w - 210; el.querySelector('.callout').style.transform = flip ? `translate(${s.x - 24}px, ${cy}px) translate(-100%, -50%)` : `translate(${s.x + 24}px, ${cy}px) translate(0, -50%)`;
}
host.querySelectorAll('[data-k]').forEach((e) => { if (!seen.has(e.dataset.k)) e.remove(); });
// witness sensors
+32 -27
View File
@@ -10,7 +10,8 @@ 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 },
cam: { lat: 28.2, lon: 85.35, range: 0.0046, heading: 0.32, tilt: 0.98 },
labels: [[28.1600, 85.3395, 'Syaphrubesi'], [28.2560, 85.3690, 'Timure'], [28.2802, 85.3797, 'Rasuwagadhi bridge']],
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%'] },
@@ -30,24 +31,25 @@ const THREADS = {
{ 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 } },
{ 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.268, lon: 85.37, range: 0.0032, heading: 3.55, tilt: 0.98 } },
{ at: KT + 20 * MIN, t: '08:05', text: 'The crest reaches Rasuwagadhi and the border bridge.', cites: ['H2', 'O7'], cam: { lat: 28.276, lon: 85.376, range: 0.0026, heading: 2.6, tilt: 0.95 } },
{ 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.252, lon: 85.368, range: 0.0027, heading: 3.4, tilt: 0.96 } },
{ at: KT + 61 * MIN, t: '08:46', text: 'Debris piles against the Syaphrubesi bridge deck.', cites: ['O6', 'H4'], cam: { lat: 28.16, lon: 85.338, range: 0.0027, heading: 0.25, tilt: 0.96 } },
{ 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.155, lon: 85.332, range: 0.0032, heading: 0.9, tilt: 0.98 } },
{ at: KT + 140 * MIN, t: '10:05', text: 'First rescue sortie climbs the valley.', cites: ['O8'], cam: { lat: 28.2, lon: 85.35, range: 0.0045, heading: 0.3, tilt: 0.98 } },
],
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'],
id: 'concert', sit: 'concert', title: 'Stadium encore in 4D', place: 'Wembley, London', date: GE.dayWord(GE.T_CONCERT, 'night'),
window: { start: GE.T_CONCERT - 1 * HR_, end: GE.T_CONCERT + 0.5 * HR_ }, people: ['crowd', 'sky', 'kora', 'geo'],
cam: { lat: 51.5546, lon: -0.2822, range: 0.00042, heading: 0.6, tilt: 1.1 },
labels: [[51.556, -0.2803, 'Stage · encore', 0.05]],
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: 'O1', i: 'seal', f: 'eyes', t: '2,311 signed phone captures from 86 seat blocks', src: 'Signed bundles (illustrative)', at: GE.T_CONCERT, 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: GE.T_CONCERT + 0.2 * HR_, meta: ['common track'] },
{ id: 'O3', i: 'video', f: 'eyes', t: 'Venue public camera, concourse north', src: 'Public feed (illustrative)', at: GE.T_CONCERT, 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: [
@@ -55,21 +57,22 @@ const THREADS = {
{ 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 } },
{ at: GE.T_CONCERT - 3 * 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: GE.T_CONCERT, 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: GE.T_CONCERT + 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 },
id: 'climb', sit: 'climb', title: 'Free-climber on a Midtown tower', place: 'Midtown Manhattan (fictional tower)', date: GE.dayWord(GE.T_CLIMB, 'morning'),
window: { start: GE.T_CLIMB - 0.5 * HR_, end: GE.T_CLIMB + 3 * HR_ }, people: ['news', 'sky', 'kora', 'geo', 'ais'],
cam: { lat: 40.7532, lon: -73.9865, range: 0.00055, heading: 0.62, tilt: 1.12 },
labels: [[40.7553, -73.9838, 'Meridian Tower (fictional)', 0.43]],
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: 'O1', i: 'helicopter', f: 'sky', t: 'ADS-B tracks: 3 news helicopters orbiting the tower', src: 'ADS-B (illustrative callsigns)', at: GE.T_CLIMB + 1.2 * HR_, meta: ['1 Hz', '2 h 14 m'] },
{ id: 'O2', i: 'video', f: 'eyes', t: 'Broadcast frames, time-coded', src: 'Public broadcast (illustrative)', at: GE.T_CLIMB + 1.2 * HR_, meta: ['29.97 fps'] },
{ id: 'O3', i: 'seal', f: 'eyes', t: '41 signed street-level clips', src: 'Signed bundles (illustrative)', at: GE.T_CLIMB + 1.2 * 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: [
@@ -77,9 +80,9 @@ const THREADS = {
{ 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 } },
{ at: GE.T_CLIMB, 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: GE.T_CLIMB + 63 * MIN, 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: GE.T_CLIMB + 134 * MIN, 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',
@@ -91,6 +94,7 @@ THR.data = THREADS;
const EXTRAS = {};
GE.extrasFor = function (id) {
if (EXTRAS[id] !== undefined) return EXTRAS[id];
if (id === 'launch') { const sp = [], ln = []; const pad = [28.5618, -80.5772]; pushSpr(sp, geo.vec(pad[0], pad[1], 0.04), [1, 0.86, 0.6, 0.9], 70, 0, -10, -1e9, 1e9, 0, 0, 0.9); for (const [dx, dy] of [[90, 70], [-80, 60], [70, -80], [-90, -70]]) { const [la, lo] = geo.offset(pad[0], pad[1], dx, dy); pushSpr(sp, geo.vec(la, lo, 0.02), [1, 0.9, 0.7, 0.8], 26, 0, -10, -1e9, 1e9, 0, 0, 0.6); } return (EXTRAS[id] = { sprites: [makeSpriteSet('xl', sp)], lines: null }); }
const T = THREADS[id] || (GE.SIT.byId[id]?.thread && THREADS[GE.SIT.byId[id].thread]);
if (!T) return (EXTRAS[id] = null);
const sp = [], ln = [];
@@ -121,6 +125,7 @@ GE.extrasFor = function (id) {
const set = { sprites: sp.length ? [makeSpriteSet('x' + id, sp)] : null, lines: ln.length ? [makeLineSet(ln)] : null };
return (EXTRAS[id] = set);
};
GE.setLabels3d = function (L) { GE.extraLabels.clear(); for (const [la, lo, t, alt] of L || []) { const z = GE.scenes.byId.koshi.height && Math.abs(la - 28.2) < 0.2 && Math.abs(lo - 85.35) < 0.2 ? GE.scenes.groundAt(la, lo) / 1000 + 0.12 : alt || 0.05; GE.extraLabels.set('x:' + t, { lat: la, lon: lo, alt: z, html: `<i style="--c:var(--gold)"></i>${esc(t)}`, w: t.length * 6.6 + 32, cls: 'glass' }); } };
GE.setExtras = function (id) { GE.world.activeExtras = id ? GE.extrasFor(id) : null; GE.world.activeExtrasId = id; };
/* ---------- ledger (real SHA-256 hash chain) ---------- */
@@ -172,7 +177,7 @@ THR.openWs = function (id) {
$('#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);
GE.setExtras(T.id); GE.setLabels3d(T.labels);
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 });
@@ -182,12 +187,12 @@ THR.openWs = function (id) {
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('');
W.setWindow(null); W.setTime(null); GE.setExtras(null); GE.setLabels3d(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>
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 class="sub">${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());
+1 -1
View File
@@ -43,7 +43,7 @@ function frame(now) {
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 (APP.intro || !(Cam.anim || Cam.interacting || GE.thumbs._busyUntil > now)) 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);