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

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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+49
View File
@@ -0,0 +1,49 @@
// Builds mobile/vision/index.html — one self-contained file (no runtime network).
//
// node mobile/vision/build.mjs # build once
// node mobile/vision/build.mjs --watch # rebuild on change
//
// Inputs (all offline, commit-friendly):
// src/app.html page template; markers <!--@CSS--> <!--@JS--> <!--@TEX--> are replaced
// src/app.css styles
// src/js/*.js concatenated in filename order inside one IIFE
// assets/*.webp NASA public-domain textures (made by prep-textures.mjs), inlined as data: URIs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const rd = (p) => fs.readFileSync(path.join(here, p), 'utf8');
function build() {
const t0 = Date.now();
const tpl = rd('src/app.html');
const css = rd('src/app.css');
const jsDir = path.join(here, 'src/js');
const files = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js')).sort();
const js = files.map((f) => `// ---- ${f}\n` + fs.readFileSync(path.join(jsDir, f), 'utf8')).join('\n');
const tex = {};
for (const f of fs.readdirSync(path.join(here, 'assets')).sort()) {
if (!/\.(webp|png|jpg)$/.test(f)) continue;
const ext = f.split('.').pop();
const mime = ext === 'jpg' ? 'image/jpeg' : 'image/' + ext;
tex[f.replace(/\.\w+$/, '')] = `data:${mime};base64,` + fs.readFileSync(path.join(here, 'assets', f)).toString('base64');
}
const texScript = `<script>window.__GE_TEX=${JSON.stringify(tex)};</script>`;
const appScript = `<script>\n(() => {\n'use strict';\n${js}\n})();\n</script>`;
let out = tpl
.replace('<!--@CSS-->', () => `<style>\n${css}\n</style>`)
.replace('<!--@TEX-->', () => texScript)
.replace('<!--@JS-->', () => appScript);
fs.writeFileSync(path.join(here, 'index.html'), out);
const kb = (Buffer.byteLength(out) / 1024).toFixed(0);
console.log(`built index.html ${kb} KB (${files.length} js files, ${Object.keys(tex).length} textures, ${Date.now() - t0} ms)`);
}
build();
if (process.argv.includes('--watch')) {
let timer;
for (const d of ['src', 'src/js', 'assets'])
fs.watch(path.join(here, d), () => { clearTimeout(timer); timer = setTimeout(() => { try { build(); } catch (e) { console.error(e); } }, 80); });
console.log('watching…');
}
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
// One-time texture prep: NASA public-domain sources -> small WebP files in ./assets.
// Usage: node mobile/vision/prep-textures.mjs <dir-with-downloads>
// Sources (public domain, NASA): Blue Marble NG (cloudless day), Black Marble 2016 (night lights,
// https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km.jpg),
// Blue Marble clouds, land/water mask and topography ([email protected] example images).
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const sharp = require('sharp');
const here = path.dirname(fileURLToPath(import.meta.url));
const src = process.argv[2];
if (!src) { console.error('usage: node prep-textures.mjs <download-dir>'); process.exit(1); }
const out = (f) => path.join(here, 'assets', f);
const jobs = [
['earth-blue-marble.jpg', 'day.webp', (s) => s.resize(4096, 2048).webp({ quality: 74, smartSubsample: true, effort: 6 })],
['BlackMarble_2016_3km.jpg', 'night.webp', (s) => s.resize(4096, 2048, { kernel: 'lanczos3' }).webp({ quality: 74, smartSubsample: true, effort: 6 })],
['clouds.png', 'clouds.webp', (s) => s.resize(2048, 1024).extractChannel('alpha').webp({ quality: 66, effort: 6 })],
['earth-water.png', 'water.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 60, effort: 6 })],
['earth-topology.png', 'topo.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 74, effort: 6 })],
];
for (const [i, o, fn] of jobs) {
const info = await fn(sharp(path.join(src, i))).toFile(out(o));
console.log(o, info.width + 'x' + info.height, (info.size / 1024).toFixed(0) + ' KB');
}
+567
View File
@@ -0,0 +1,567 @@
/* God's Eye — vision. Dark-first, Liquid-Glass-era shell. */
:root {
color-scheme: dark;
--bg: #05070B; --bg2: #0A0E15; --card: #0C1118; --card2: #111723;
--text: #F3F6FB; --text2: rgba(232,238,248,.68); --text3: rgba(232,238,248,.44); --text4: rgba(232,238,248,.26);
--sep: rgba(255,255,255,.08); --stroke: rgba(255,255,255,.16);
--sky: #7CC4FF; --sea: #38E1C6; --orbit: #C9A7FF; --haz: #FF6A3D; --eyes: #FFD166; --ground: #9BE15D; --wx: #8AB4F8; --infra: #B8C0CC;
--gold: #E9C46A; --gold2: #F6DFA0; --live: #FF453A; --ok: #30D158; --warn: #FFD60A; --ai: #B79CFF;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro", "Inter", system-ui, "Segoe UI", Roboto, sans-serif;
--display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter Display", "Inter", system-ui, sans-serif;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, "DejaVu Sans Mono", monospace;
--sat: env(safe-area-inset-top, 0px); --sab: env(safe-area-inset-bottom, 0px); --sal: env(safe-area-inset-left, 0px); --sar: env(safe-area-inset-right, 0px);
--paneW: 0px; --tabH: 62px; --spring: cubic-bezier(.2,.9,.25,1.12); --out: cubic-bezier(.16,1,.3,1);
--glass: rgba(24,29,38,.46); --glass-strong: rgba(16,20,28,.72);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); overflow: hidden; overscroll-behavior: none; }
body { font: 15px/1.38 var(--font); color: var(--text); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; text-rendering: optimizeLegibility; font-feature-settings: "ss01", "cv11"; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }
button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
input, textarea { font: inherit; color: inherit; }
.num, .tnum { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
.mono { font-family: var(--mono); font-size: .92em; letter-spacing: -.01em; }
.ic { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; overflow: visible; }
.ic.f { fill: currentColor; stroke: none; }
.ic.sm { width: 16px; height: 16px; stroke-width: 1.8; } .ic.xs { width: 13px; height: 13px; stroke-width: 2; } .ic.lg { width: 28px; height: 28px; }
.hide { display: none !important; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
/* ---------- glass ---------- */
.glass { position: relative; background: linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.025) 60%), var(--glass); -webkit-backdrop-filter: blur(24px) saturate(1.6); backdrop-filter: blur(24px) saturate(1.6);
box-shadow: inset 0 .5px 0 rgba(255,255,255,.34), inset 0 0 0 .5px rgba(255,255,255,.10), inset 0 -.5px 0 rgba(255,255,255,.05); }
.glass::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: radial-gradient(120% 60% at 20% -10%, rgba(255,255,255,.14), transparent 55%); mix-blend-mode: screen; }
.glass.strong { background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.02) 60%), var(--glass-strong); }
.pill { border-radius: 999px; }
.circ { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; }
/* ---------- app & canvas ---------- */
.app { position: fixed; inset: 0; overflow: hidden; background: var(--bg); }
#gl { position: absolute; left: var(--paneW); top: 0; width: calc(100% - var(--paneW)); height: 100%; display: block; touch-action: none; outline: none; }
.globe-area { position: absolute; left: var(--paneW); top: 0; right: 0; bottom: 0; pointer-events: none; }
.labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.lbl { position: absolute; left: 0; top: 0; transform: translate(-50%, -100%); pointer-events: auto; display: flex; align-items: center; gap: 6px; padding: 5px 10px 5px 7px; font-size: 12px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; border-radius: 999px; color: var(--text); transition: opacity .25s; }
.lbl i { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--sky)); box-shadow: 0 0 10px var(--c, var(--sky)); flex: none; }
.lbl small { font-weight: 500; color: var(--text2); font-size: 11px; }
.lbl.sel { box-shadow: inset 0 0 0 1px var(--gold), inset 0 .5px 0 rgba(255,255,255,.3); }
.lbl.plain { background: none; backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none; text-shadow: 0 1px 6px rgba(0,0,0,.9); padding: 2px 4px; font-weight: 600; }
.lbl.plain::after { display: none; }
.lbl.tag { transform: translate(10px, -50%); }
/* ---------- views ---------- */
.view { position: absolute; inset: 0; overflow: hidden auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: none; scrollbar-width: none; }
.view::-webkit-scrollbar { display: none; }
.view.on { display: block; animation: viewIn .38s var(--out); }
@keyframes viewIn { from { opacity: 0; transform: translateY(8px) scale(.995); } }
.pane { position: absolute; left: 0; top: 0; bottom: 0; width: 100%; }
/* large-title nav */
.navbar { position: sticky; top: 0; z-index: 5; height: calc(var(--sat) + 48px); padding-top: var(--sat); display: grid; place-items: center; font-weight: 600; font-size: 17px; opacity: 0; transition: opacity .2s; pointer-events: none; margin-bottom: calc(-48px - var(--sat)); }
.navbar.on { opacity: 1; pointer-events: auto; }
.navbar::before { content: ""; position: absolute; inset: 0; background: rgba(5,7,11,.62); -webkit-backdrop-filter: blur(20px) saturate(1.5); backdrop-filter: blur(20px) saturate(1.5); border-bottom: .5px solid var(--sep); z-index: -1; }
.lt { padding: calc(var(--sat) + 14px) 20px 6px; }
.lt .kicker { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--text3); display: flex; gap: 8px; align-items: center; }
.lt h1 { margin: 2px 0 0; font: 700 34px/41px var(--display); letter-spacing: .2px; }
.lt .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700; color: #fff; background: conic-gradient(from 200deg, hsl(var(--h,205) 80% 55%), hsl(calc(var(--h,205) + 60) 75% 45%), hsl(var(--h,205) 80% 55%)); box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.18); flex: none; }
.avatar.sm { width: 22px; height: 22px; font-size: 9px; } .avatar.md { width: 28px; height: 28px; font-size: 11px; } .avatar.xl { width: 72px; height: 72px; font-size: 26px; }
.avatar.ai { background: radial-gradient(circle at 30% 30%, #E6DBFF, #8E6CFF 55%, #4A2FB8); }
.stack { display: flex; } .stack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px var(--bg), inset 0 0 0 1.5px rgba(255,255,255,.18); } .stack .avatar:first-child { margin-left: 0; }
.livebar { position: relative; z-index: 1; display: flex; gap: 14px; align-items: center; font-size: 13px; color: var(--text2); padding: 4px 20px 0; flex-wrap: wrap; }
.livebar b { color: var(--text); font-weight: 600; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--live); display: inline-block; flex: none; }
.dot.pulse { animation: pulse 1.8s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,69,58,.55); } 70% { box-shadow: 0 0 0 7px rgba(255,69,58,0); } 100% { box-shadow: 0 0 0 0 rgba(255,69,58,0); } }
.chips { display: flex; gap: 8px; overflow-x: auto; padding: 12px 20px 6px; scrollbar-width: none; }
.chips::-webkit-scrollbar { display: none; }
.chip { flex: none; height: 34px; padding: 0 14px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); transition: background .2s, color .2s; white-space: nowrap; }
.chip.on { background: var(--text); color: #05070B; }
.chip .n { font-weight: 500; opacity: .6; }
/* ---------- Now ---------- */
#v-now { background: transparent; }
.now-hero { height: calc(var(--sat) + clamp(290px, 41vh, 400px)); position: relative; display: flex; flex-direction: column; justify-content: flex-end; pointer-events: none; }
.now-hero > * { pointer-events: auto; }
.now-hero::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 90px; background: linear-gradient(to bottom, rgba(5,7,11,0), var(--bg)); pointer-events: none; }
.now-hero .lt { position: relative; z-index: 1; padding-bottom: 0; text-shadow: 0 2px 18px rgba(0,0,0,.6); }
.now-top { position: absolute; top: calc(var(--sat) + 10px); left: 16px; right: 16px; display: flex; justify-content: space-between; align-items: center; z-index: 2; }
.now-body { background: var(--bg); min-height: 60vh; padding-bottom: calc(var(--sab) + 150px); }
.sec-h { display: flex; align-items: baseline; justify-content: space-between; padding: 18px 20px 10px; }
.sec-h h2 { margin: 0; font: 700 22px/28px var(--display); letter-spacing: .1px; }
.sec-h span { color: var(--text3); font-size: 13px; }
.masonry { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 12px; align-items: start; }
.masonry .col { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.card { position: relative; border-radius: 18px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; transform: translateZ(0); transition: transform .35s var(--spring); }
.card:active { transform: scale(.975); }
.card .thumb { position: relative; width: 100%; background: #070A10; overflow: hidden; }
.card .thumb canvas, .card .thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.card .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.28), transparent 26%, transparent 55%, rgba(0,0,0,.66)); pointer-events: none; }
.card .tl { position: absolute; top: 9px; left: 9px; z-index: 2; display: flex; gap: 6px; }
.badge { height: 22px; padding: 0 8px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: rgba(10,12,18,.5); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); }
.badge.live { color: #fff; } .badge.live .dot { width: 6px; height: 6px; }
.badge.fam i { width: 7px; height: 7px; border-radius: 50%; background: var(--c); box-shadow: 0 0 8px var(--c); }
.badge.d4 { color: var(--gold2); } .badge.replay { color: var(--text2); }
.card .tr { position: absolute; top: 9px; right: 9px; z-index: 2; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: rgba(10,12,18,.42); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); color: var(--c); }
.card .bl { position: absolute; left: 11px; right: 11px; bottom: 9px; z-index: 2; }
.card .metric { font: 700 21px/1.05 var(--display); letter-spacing: -.01em; text-shadow: 0 1px 10px rgba(0,0,0,.6); }
.card .metric small { font-size: 12px; font-weight: 600; color: var(--text2); margin-left: 3px; letter-spacing: 0; }
.card .mlabel { font-size: 11.5px; color: var(--text2); margin-top: 2px; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
.card .meta { padding: 9px 11px 11px; }
.card .ttl { font-size: 14.5px; font-weight: 600; line-height: 1.28; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .src { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 7px; font-size: 11.5px; color: var(--text3); }
.card .src .l { display: flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.card .src .r { display: flex; align-items: center; gap: 4px; flex: none; }
.card .cta { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px; background: rgba(155,225,93,.14); color: var(--ground); font-size: 12.5px; font-weight: 700; }
.card.flash .thumb { animation: flash 1.2s ease; }
@keyframes flash { 30% { filter: brightness(1.35) saturate(1.2); } }
.card .spark { position: absolute; right: 10px; bottom: 12px; z-index: 2; opacity: .9; }
/* ---------- tab bar & accessory ---------- */
.tabwrap { position: absolute; left: 0; width: var(--tabW, 100%); bottom: 0; padding: 0 12px calc(var(--sab) + 10px); display: flex; gap: 10px; align-items: flex-end; justify-content: center; z-index: 40; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
.tabwrap > * { pointer-events: auto; }
.tabbar { height: var(--tabH); border-radius: 31px; display: flex; align-items: center; padding: 0 4px; gap: 0; transition: width .45s var(--out); }
.tab { position: relative; z-index: 1; width: 66px; height: 54px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-size: 10.5px; font-weight: 600; color: var(--text2); border-radius: 27px; transition: color .25s; letter-spacing: .01em; }
.tab .ic { width: 24px; height: 24px; }
.tab.on { color: var(--sky); }
.tablens { position: absolute; z-index: 0; top: 4px; height: 54px; width: 66px; border-radius: 27px; background: rgba(255,255,255,.12); box-shadow: inset 0 .5px 0 rgba(255,255,255,.3), inset 0 0 0 .5px rgba(255,255,255,.1); transition: transform .5s var(--spring), width .3s; }
.searchbtn { width: var(--tabH); height: var(--tabH); border-radius: 50%; display: grid; place-items: center; flex: none; }
.searchbtn .ic { width: 24px; height: 24px; }
.tabwrap.mini .tab:not(.on) { width: 0; opacity: 0; overflow: hidden; padding: 0; }
.tabwrap.mini .tab.on { width: 54px; }
.tabwrap.mini .tab.on span { display: none; }
.tabwrap.mini .tablens { width: 54px; }
.tabwrap.mini .tabbar { width: auto; }
.tabwrap.hidden { transform: translateY(140%); opacity: 0; pointer-events: none; }
.tabwrap.hidden > * { pointer-events: none; }
.accessory { height: 48px; border-radius: 24px; display: flex; align-items: center; gap: 10px; padding: 0 6px 0 14px; min-width: 0; flex: 1; max-width: 520px; transition: all .45s var(--out); }
.acc-host { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); display: flex; justify-content: center; z-index: 39; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
.acc-host > * { pointer-events: auto; }
.acc-host.hidden { opacity: 0; transform: translateY(30px); pointer-events: none; }
.acc-host.hidden > * { pointer-events: none; }
.accessory .ttl { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.accessory .sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.accessory .txt { min-width: 0; flex: 1; line-height: 1.2; }
.accessory .ab { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; }
.tabwrap .accessory { display: none; }
.tabwrap.mini .accessory { display: flex; }
/* ---------- globe chrome ---------- */
.gtop { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); right: 12px; display: flex; justify-content: space-between; align-items: flex-start; z-index: 20; pointer-events: none; }
.gtop > * { pointer-events: auto; }
.status { height: 40px; padding: 0 14px 0 12px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
.status .t { color: var(--text2); font-weight: 500; }
.gbtns { display: flex; flex-direction: column; gap: 10px; }
.gbtn { width: 44px; height: 44px; border-radius: 22px; display: grid; place-items: center; }
.gbtn.on { color: var(--sky); }
.compass .ic { transition: transform .1s linear; }
.gbottom { position: absolute; left: calc(var(--paneW) + 0px); right: 0; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); z-index: 20; display: flex; flex-direction: column; gap: 10px; padding: 0 12px; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
.gbottom > * { pointer-events: auto; }
.dock { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 2px; margin: -2px; }
.dock::-webkit-scrollbar { display: none; }
.fam { flex: none; height: 38px; padding: 0 13px 0 10px; border-radius: 19px; display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 600; color: var(--text); transition: opacity .2s, transform .3s var(--spring); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.fam .ic { color: var(--c); width: 18px; height: 18px; }
.fam .n { color: var(--text3); font-weight: 500; font-size: 12px; }
.fam.off { opacity: .5; } .fam.off .ic { color: var(--text3); }
.fam.solo { box-shadow: inset 0 0 0 1.5px var(--c); }
.fam:active { transform: scale(.94); }
/* time ribbon */
.ribbon { height: 62px; border-radius: 22px; position: relative; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
.ribbon canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.ribbon .head { position: absolute; left: 50%; top: 6px; bottom: 6px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--text); box-shadow: 0 0 12px rgba(255,255,255,.6); pointer-events: none; z-index: 2; }
.ribbon .readout { position: absolute; left: 12px; top: 7px; font-size: 12px; font-weight: 600; z-index: 3; pointer-events: none; display: flex; gap: 6px; align-items: center; }
.ribbon .readout .rel { color: var(--text3); font-weight: 500; }
.ribbon .nowbtn { position: absolute; right: 6px; top: 6px; height: 26px; padding: 0 10px; border-radius: 13px; font-size: 11.5px; font-weight: 800; letter-spacing: .06em; z-index: 3; display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,.08); color: var(--text2); transition: all .25s; }
.ribbon .nowbtn.live { background: rgba(255,69,58,.18); color: #FF8A80; }
.ribbon .fc { position: absolute; right: 74px; top: 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: var(--wx); z-index: 3; opacity: 0; transition: opacity .2s; }
.ribbon.future .fc { opacity: 1; }
.ribbon.breath::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px rgba(255,69,58,.0); animation: breath 3.2s ease-in-out infinite; pointer-events: none; z-index: 1; }
@keyframes breath { 50% { box-shadow: inset 0 0 0 1px rgba(255,120,110,.32), inset 0 0 18px rgba(255,69,58,.12); } }
/* pick card */
.pick { border-radius: 26px; padding: 12px 12px 12px 14px; display: none; flex-direction: column; gap: 10px; animation: rise .4s var(--spring); }
.pick.on { display: flex; }
@keyframes rise { from { opacity: 0; transform: translateY(16px) scale(.98); } }
.pick .hd { display: flex; align-items: center; gap: 11px; }
.pick .ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); flex: none; }
.pick .tt { flex: 1; min-width: 0; } .pick .tt b { display: block; font-size: 16px; } .pick .tt span { font-size: 12.5px; color: var(--text2); }
.pick .x { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.08); color: var(--text2); }
.kv { display: flex; gap: 18px; font-size: 12px; color: var(--text3); }
.kv b { display: block; color: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.acts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.act { height: 52px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; font-size: 11px; font-weight: 600; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
.act .ic { width: 20px; height: 20px; }
.act.pri { background: rgba(124,196,255,.18); color: var(--sky); }
.act.gold { background: rgba(233,196,106,.16); color: var(--gold2); }
.act:active { transform: scale(.95); }
/* layer grid (Control Center) */
.cc { position: absolute; inset: 0; z-index: 95; display: none; background: rgba(3,5,8,.38); -webkit-backdrop-filter: blur(30px) saturate(1.4) brightness(.8); backdrop-filter: blur(30px) saturate(1.4) brightness(.8); overflow: auto; padding: calc(var(--sat) + 14px) 16px calc(var(--sab) + 40px); }
.cc.on { display: block; animation: ccIn .42s var(--out); }
@keyframes ccIn { from { opacity: 0; transform: scale(1.04); } }
.cc .cch { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.cc .cch h2 { margin: 0; font: 700 28px/34px var(--display); }
.cc .cch p { margin: 2px 0 0; color: var(--text3); font-size: 13px; }
.lenses { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 6px 0 14px; }
.lens { border-radius: 16px; padding: 8px 4px 7px; display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
.lens .sw { width: 100%; height: 38px; border-radius: 11px; }
.lens.on { color: var(--text); box-shadow: inset 0 0 0 1.5px var(--sky); }
.ccfam { margin-top: 14px; }
.ccfam .fh { display: flex; align-items: center; gap: 8px; margin: 0 2px 8px; font-size: 13px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text2); user-select: none; -webkit-user-select: none; }
.ccfam .fh i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 10px var(--c); }
.ccfam .fh .solo { margin-left: auto; font-size: 11px; font-weight: 700; color: var(--text3); letter-spacing: .04em; padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,.06); }
.ccgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.tile { border-radius: 20px; padding: 11px 12px; display: flex; gap: 10px; align-items: center; min-height: 64px; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); text-align: left; transition: background .2s, transform .2s var(--spring); }
.tile:active { transform: scale(.96); }
.tile .ti { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.1); color: var(--text2); flex: none; transition: all .2s; }
.tile .ti .ic { width: 19px; height: 19px; }
.tile .tn { font-size: 13px; font-weight: 600; line-height: 1.2; }
.tile .tc { font-size: 12px; color: var(--text3); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
.tile .tc .st { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
.tile .tc .st.stale { background: transparent; box-shadow: inset 0 0 0 1px var(--text3); }
.tile.on { background: rgba(255,255,255,.92); color: #0A0D12; }
.tile.on .ti { background: var(--c); color: #071018; }
.tile.on .tc { color: rgba(10,13,18,.55); }
/* ---------- sheet ---------- */
.sheet { position: absolute; left: 0; right: 0; bottom: 0; height: calc(100% - var(--sat) - 8px); z-index: 30; border-radius: 30px 30px 0 0; transform: translateY(100%); transition: transform .5s var(--out); display: flex; flex-direction: column; will-change: transform; touch-action: none; }
.sheet.drag { transition: none; }
.sheet .grab { flex: none; height: 26px; display: grid; place-items: center; cursor: grab; }
.sheet .grab::before { content: ""; width: 38px; height: 5px; border-radius: 3px; background: rgba(255,255,255,.3); }
.sheet .sc { flex: 1; overflow: hidden auto; padding: 0 18px calc(var(--sab) + 30px); overscroll-behavior: contain; touch-action: pan-y; scrollbar-width: none; }
.sheet .sc::-webkit-scrollbar { display: none; }
.sh-hd { display: flex; align-items: flex-start; gap: 10px; }
.sh-hd .t { flex: 1; min-width: 0; }
.sh-hd h2 { margin: 6px 0 2px; font: 700 23px/28px var(--display); letter-spacing: -.005em; }
.sh-hd .pl { color: var(--text2); font-size: 13.5px; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; margin: 14px 0 4px; }
.metric { padding: 0 6px 0 0; }
.metric .v { font: 700 26px/1.05 var(--display); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
.metric .v small { font-size: 13px; font-weight: 600; color: var(--text2); margin-left: 2px; letter-spacing: 0; }
.metric .l { font-size: 12px; color: var(--text2); margin-top: 3px; }
.metric .s { font-size: 10.5px; color: var(--text3); margin-top: 2px; }
.sh-acts { margin: 14px 0 6px; }
.brief { margin-top: 16px; }
.brief p { margin: 0 0 12px; font-size: 15px; line-height: 1.5; color: rgba(240,244,250,.86); }
.brief h3 { margin: 22px 0 8px; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
.sline { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; padding: 9px 0; border-top: .5px solid var(--sep); }
.sline .lab { font-size: 13.5px; font-weight: 600; }
.sline .val { font-size: 13.5px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.sline svg { grid-column: 1 / -1; width: 100%; height: 46px; overflow: visible; }
.sline .src { grid-column: 1 / -1; font-size: 11px; color: var(--text3); display: flex; justify-content: space-between; }
.multi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.multi figure { margin: 0; }
.multi canvas, .multi svg { width: 100%; aspect-ratio: 1; border-radius: 10px; display: block; background: #070A10; }
.multi figcaption { font-size: 11px; color: var(--text2); margin-top: 4px; text-align: center; font-variant-numeric: tabular-nums; }
.multi figcaption b { color: var(--text); }
.evid { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 -18px; padding: 0 18px; }
.evid::-webkit-scrollbar { display: none; }
.ev { flex: none; width: 116px; border-radius: 14px; overflow: hidden; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
.ev .im { height: 84px; position: relative; background-size: cover; background-position: center; }
.ev .im .seal { position: absolute; right: 6px; bottom: 6px; }
.ev .cap { padding: 6px 8px 8px; font-size: 11px; color: var(--text2); line-height: 1.3; }
.ev .cap b { color: var(--text); font-weight: 600; display: block; }
.srcs { font-size: 12.5px; }
.srcs .r { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; padding: 8px 0; border-top: .5px solid var(--sep); }
.srcs .r .st { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.srcs .r .st.stale { background: none; box-shadow: inset 0 0 0 1.2px var(--text3); }
.srcs .r span { color: var(--text3); font-variant-numeric: tabular-nums; }
.backbtn { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); z-index: 35; display: none; }
.backbtn.on { display: grid; }
.sitbtns { position: absolute; top: calc(var(--sat) + 10px); right: 12px; z-index: 35; display: none; gap: 10px; }
.sitbtns.on { display: flex; }
.seal { width: 22px; height: 22px; color: var(--gold); filter: drop-shadow(0 0 6px rgba(233,196,106,.6)); }
/* morph */
.morph { position: absolute; z-index: 50; overflow: hidden; pointer-events: none; display: none; will-change: transform, border-radius, opacity; }
.morph canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.morph.on { display: block; }
/* ---------- ride HUD ---------- */
.ride { position: absolute; inset: 0; z-index: 45; display: none; pointer-events: none; }
.ride.on { display: block; animation: viewIn .5s var(--out); }
.ride > * { pointer-events: auto; }
.ride .top { position: absolute; top: calc(var(--sat) + 10px); left: 12px; right: 12px; display: flex; gap: 10px; align-items: center; }
.ride .top .who { flex: 1; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 14px; font-size: 13px; }
.ride .top .who b { font-size: 14px; }
.hud { position: absolute; inset: 0; pointer-events: none !important; font-family: var(--mono); color: rgba(190,255,200,.9); text-shadow: 0 0 6px rgba(80,255,140,.5); }
.hud svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.hud .tape { position: absolute; top: 50%; transform: translateY(-50%); font-size: 15px; font-weight: 600; padding: 6px 9px; border: 1px solid rgba(190,255,200,.55); border-radius: 6px; background: rgba(0,20,8,.25); }
.hud .tape small { display: block; font-size: 9.5px; opacity: .7; font-weight: 500; }
.chat { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 72px); height: 46%; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; pointer-events: none !important; mask-image: linear-gradient(to top, #000 70%, transparent); -webkit-mask-image: linear-gradient(to top, #000 70%, transparent); }
.bub { align-self: flex-start; max-width: 84%; display: flex; gap: 8px; align-items: flex-end; animation: bubIn .5s var(--spring); }
.bub .bx { padding: 8px 12px; border-radius: 18px 18px 18px 6px; font-size: 13.5px; line-height: 1.35; }
.bub .bx b { display: block; font-size: 11.5px; color: var(--text2); font-weight: 600; margin-bottom: 1px; }
@keyframes bubIn { from { opacity: 0; transform: translateY(20px) scale(.96); } }
.react { position: absolute; right: 16px; bottom: calc(var(--sab) + 130px); width: 40px; height: 300px; pointer-events: none !important; }
.react span { position: absolute; bottom: 0; left: 8px; animation: floatUp 2.6s ease-out forwards; color: var(--live); }
@keyframes floatUp { to { transform: translate(var(--dx, 0px), -280px) scale(1.2); opacity: 0; } }
.compose { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 12px); display: flex; gap: 8px; }
.compose .in { flex: 1; height: 48px; border-radius: 24px; display: flex; align-items: center; padding: 0 16px; color: var(--text3); font-size: 14px; }
.compose .in input { flex: 1; background: none; border: 0; outline: none; color: var(--text); font-size: 15px; min-width: 0; }
/* ---------- capture ---------- */
#v-capture { background: #000; z-index: 25; }
#v-capture.on { display: block; }
.cap-ui { position: absolute; inset: 0; pointer-events: none; }
.cap-ui > * { pointer-events: auto; }
.cap-top { position: absolute; top: calc(var(--sat) + 8px); left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.cap-top .st { height: 34px; padding: 0 12px; border-radius: 17px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 7px; }
.compass-tape { position: absolute; top: calc(var(--sat) + 52px); left: 0; right: 0; height: 40px; pointer-events: none !important; }
.callouts { position: absolute; inset: 0; pointer-events: none !important; }
.callout { position: absolute; left: 0; top: 0; pointer-events: auto; display: flex; align-items: center; gap: 8px; padding: 6px 11px 6px 8px; border-radius: 14px; font-size: 12px; transform: translate(14px, -50%); white-space: nowrap; }
.callout b { font-size: 13px; display: block; }
.callout span { color: var(--text2); font-size: 11px; }
.callout .ci { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 22%, transparent); color: var(--c); }
.reticle { position: absolute; width: 34px; height: 34px; margin: -17px 0 0 -17px; border: 1.5px solid var(--c, #fff); border-radius: 10px; pointer-events: none; box-shadow: 0 0 12px var(--c); }
.cap-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 16px calc(var(--sab) + 18px); display: flex; flex-direction: column; align-items: center; gap: 14px; background: linear-gradient(to top, rgba(0,0,0,.55), transparent); }
.zooms { display: flex; gap: 6px; padding: 4px; border-radius: 22px; }
.zooms button { width: 36px; height: 36px; border-radius: 50%; font-size: 12px; font-weight: 700; color: var(--text2); }
.zooms button.on { background: rgba(255,255,255,.14); color: var(--warn); }
.dial { display: flex; gap: 22px; font-size: 13px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text2); transition: transform .45s var(--spring); }
.dial button { padding: 6px 2px; }
.dial button.on { color: var(--warn); }
.shutrow { width: 100%; max-width: 440px; display: flex; align-items: center; justify-content: space-between; }
.shutter { width: 78px; height: 78px; border-radius: 50%; display: grid; place-items: center; box-shadow: 0 0 0 4px rgba(255,255,255,.92); transition: transform .2s; }
.shutter i { width: 64px; height: 64px; border-radius: 50%; background: #fff; transition: all .25s var(--spring); }
.shutter.look i { background: transparent; box-shadow: inset 0 0 0 2px rgba(255,255,255,.7); }
.shutter:active i { transform: scale(.88); }
.lastcap { width: 48px; height: 48px; border-radius: 12px; background: #111 center/cover; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); position: relative; }
.lastcap .seal { position: absolute; right: -6px; top: -6px; width: 20px; height: 20px; }
.sensors { position: absolute; left: 12px; bottom: calc(var(--sab) + 210px); border-radius: 16px; padding: 8px 10px; font-size: 11px; display: none; gap: 3px; flex-direction: column; min-width: 158px; color: var(--text2); }
.sensors.on { display: flex; }
.sensors b { color: var(--text); font-variant-numeric: tabular-nums; font-weight: 600; }
.sensors svg { width: 140px; height: 26px; }
.seal-ov { position: absolute; inset: 0; z-index: 70; display: none; align-items: center; justify-content: center; padding: calc(var(--sat) + 20px) 18px calc(var(--sab) + 20px); background: rgba(0,0,0,.55); -webkit-backdrop-filter: blur(18px) saturate(1.2); backdrop-filter: blur(18px) saturate(1.2); }
.seal-ov.on { display: flex; animation: viewIn .35s var(--out); }
.bundle { width: 100%; max-width: 420px; border-radius: 28px; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow: auto; }
.bundle .ph { position: relative; border-radius: 18px; overflow: hidden; aspect-ratio: 4/3; background: #000; }
.bundle .ph canvas, .bundle .ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bigseal { position: absolute; right: 10px; bottom: 10px; width: 92px; height: 92px; opacity: 0; transform: scale(2.2) rotate(-40deg); transition: all .7s var(--spring); }
.bigseal.on { opacity: 1; transform: scale(1) rotate(0); }
.steps { display: flex; flex-direction: column; gap: 2px; }
.step { display: grid; grid-template-columns: 24px 1fr; gap: 8px; padding: 7px 2px; border-top: .5px solid var(--sep); font-size: 13px; opacity: .35; transition: opacity .3s; }
.step.on { opacity: 1; }
.step .ck { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px var(--text3); color: transparent; transition: all .3s; }
.step.done .ck { background: var(--gold); box-shadow: none; color: #1A1408; }
.step b { font-weight: 600; } .step .h { font-family: var(--mono); font-size: 11px; color: var(--text2); word-break: break-all; line-height: 1.35; margin-top: 2px; }
.step svg { width: 100%; height: 30px; margin-top: 4px; }
.btnrow { display: flex; gap: 8px; }
.btn { flex: 1; height: 50px; border-radius: 25px; font-weight: 700; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: rgba(255,255,255,.1); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.12); }
.btn.gold { background: linear-gradient(180deg, #F3D68A, #D9AE4F); color: #1C1405; box-shadow: inset 0 .5px 0 rgba(255,255,255,.6); }
.btn.pri { background: var(--text); color: #05070B; }
.btn:active { transform: scale(.97); }
.btn.sm { height: 38px; font-size: 13.5px; flex: none; padding: 0 16px; }
/* ---------- Threads ---------- */
.tcard { margin: 0 14px 14px; border-radius: 24px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; }
.tcard .thumb { position: relative; aspect-ratio: 16/9; background: #070A10; }
.tcard .thumb canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.tcard .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgba(0,0,0,.7)); }
.tcard .bl { position: absolute; left: 14px; bottom: 12px; right: 14px; z-index: 1; display: flex; justify-content: space-between; align-items: flex-end; gap: 10px; }
.tcard .bl h3 { margin: 0; font: 700 20px/1.15 var(--display); }
.tcard .bl p { margin: 3px 0 0; font-size: 12.5px; color: var(--text2); }
.tcard .stats { display: flex; gap: 16px; padding: 12px 14px; font-size: 12px; color: var(--text3); align-items: center; }
.tcard .stats b { color: var(--text); font-size: 15px; display: block; font-variant-numeric: tabular-nums; }
.tcard .stats .stack { margin-left: auto; }
.ws { position: absolute; inset: 0; display: none; z-index: 10; }
.ws.on { display: block; animation: pushIn .45s var(--out); }
@keyframes pushIn { from { transform: translateX(30%); opacity: 0; } }
.ws .wsh { position: absolute; top: 0; left: 0; right: 0; padding: calc(var(--sat) + 10px) 12px 0; display: flex; align-items: center; gap: 10px; z-index: 3; pointer-events: none; }
.ws .wsh > * { pointer-events: auto; }
.ws .wsh .tt { flex: 1; min-width: 0; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 8px 0 14px; }
.ws .wsh .tt b { font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.ws .wsh .tt span { font-size: 11.5px; color: var(--text2); }
.ws .body { position: absolute; left: 0; right: 0; bottom: 0; top: 40%; border-radius: 28px 28px 0 0; display: flex; flex-direction: column; overflow: hidden; }
.ws .seg { display: flex; margin: 12px 14px 8px; padding: 3px; border-radius: 12px; background: rgba(255,255,255,.07); flex: none; }
.ws .seg button { flex: 1; height: 34px; border-radius: 9px; font-size: 13px; font-weight: 600; color: var(--text2); transition: all .25s; }
.ws .seg button.on { background: rgba(255,255,255,.16); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,.12) inset; }
.ws .wsc { flex: 1; overflow: auto; padding: 4px 14px calc(var(--sab) + 120px); scrollbar-width: none; }
.ws .wsc::-webkit-scrollbar { display: none; }
.stratum { font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 10px 2px 8px; display: flex; align-items: center; gap: 6px; }
.obs { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); }
.obs .oi { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); }
.obs .oi .ic { width: 18px; height: 18px; }
.obs .ot { font-size: 14px; font-weight: 600; line-height: 1.3; }
.obs .om { font-size: 11.5px; color: var(--text3); margin-top: 3px; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
.obs .om .mono { color: var(--text2); }
.obs .tagid { font-family: var(--mono); font-size: 11px; color: var(--gold2); background: rgba(233,196,106,.12); padding: 1px 6px; border-radius: 6px; }
.hyp { border-radius: 20px; padding: 13px 14px; margin: 0 0 10px; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
.hyp.ai { box-shadow: inset 0 0 0 1px rgba(183,156,255,.45); background: linear-gradient(180deg, rgba(183,156,255,.10), rgba(183,156,255,.02)), var(--card2); }
.hyp .hh { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); }
.hyp .hh b { color: var(--text); font-weight: 600; }
.hyp .hh .stt { margin-left: auto; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; padding: 3px 8px; border-radius: 999px; background: rgba(48,209,88,.14); color: #6BE38A; }
.hyp .hh .stt.disputed { background: rgba(255,214,10,.14); color: var(--warn); }
.hyp .hh .stt.proposed { background: rgba(183,156,255,.16); color: var(--ai); }
.hyp .hb { margin: 8px 0 10px; font-size: 15px; line-height: 1.42; }
.conf { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; font-size: 11.5px; color: var(--text3); }
.conf svg { width: 100%; height: 14px; }
.cites { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
.hyp .hf { display: flex; gap: 8px; margin-top: 10px; }
.vote { height: 34px; padding: 0 12px; border-radius: 17px; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; background: rgba(255,255,255,.07); color: var(--text2); }
.vote.on { background: rgba(124,196,255,.18); color: var(--sky); }
.vote.dis.on { background: rgba(255,214,10,.16); color: var(--warn); }
.composer { position: absolute; left: 10px; right: 10px; bottom: calc(var(--sab) + 10px); border-radius: 26px; padding: 8px; display: flex; flex-direction: column; gap: 8px; z-index: 5; }
.composer .sug { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
.composer .sug::-webkit-scrollbar { display: none; }
.composer .sug button { flex: none; height: 30px; padding: 0 11px; border-radius: 15px; font-size: 12px; font-weight: 600; background: rgba(183,156,255,.14); color: #D7C9FF; white-space: nowrap; }
.composer .row { display: flex; gap: 8px; align-items: center; }
.composer textarea { flex: 1; min-height: 40px; max-height: 96px; resize: none; background: rgba(255,255,255,.06); border: 0; outline: 0; border-radius: 20px; padding: 10px 14px; font-size: 14.5px; line-height: 1.35; color: var(--text); }
.composer .send { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--ai); color: #140B33; flex: none; }
.composer .send[disabled] { opacity: .5; }
.narr { display: flex; flex-direction: column; gap: 0; }
.beat { display: grid; grid-template-columns: 54px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); opacity: .55; transition: opacity .3s; cursor: pointer; }
.beat.on { opacity: 1; }
.beat .bt { font-family: var(--mono); font-size: 12px; color: var(--text2); padding-top: 2px; }
.beat.on .bt { color: var(--gold2); }
.beat p { margin: 0; font-size: 14.5px; line-height: 1.42; }
.playbar { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; }
.playbtn { width: 50px; height: 50px; border-radius: 50%; display: grid; place-items: center; background: var(--text); color: #05070B; flex: none; }
.prog { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,.12); position: relative; overflow: hidden; }
.prog i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--gold); border-radius: 2px; }
.ledger .le { border-top: .5px solid var(--sep); padding: 10px 2px; }
.ledger .le .lh { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; cursor: pointer; }
.ledger .le .ix { font-family: var(--mono); font-size: 11px; color: var(--text3); }
.ledger .le .ty { font-size: 13px; font-weight: 600; }
.ledger .le .ty span { color: var(--text3); font-weight: 500; }
.ledger .le .hs { font-family: var(--mono); font-size: 11px; color: var(--text2); }
.ledger .le .vk { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; }
.ledger .le .vk.ok { background: rgba(48,209,88,.2); color: #6BE38A; } .ledger .le .vk.bad { background: rgba(255,69,58,.25); color: #FF8A80; }
.ledger .le pre { margin: 8px 0 0 34px; font-family: var(--mono); font-size: 10.5px; line-height: 1.45; color: var(--text2); white-space: pre-wrap; word-break: break-all; background: rgba(255,255,255,.04); padding: 8px 10px; border-radius: 10px; display: none; }
.ledger .le.open pre { display: block; }
.ledger .chain { width: 2px; height: 10px; background: linear-gradient(var(--gold), transparent); margin-left: 12px; }
.presence { display: flex; align-items: center; gap: 8px; }
.presence .stack .avatar { box-shadow: 0 0 0 2px rgba(20,24,32,.9), inset 0 0 0 1.5px rgba(255,255,255,.2); }
.presence .on { position: relative; }
.presence .on::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 1.5px #10141c; }
.toastp { position: absolute; left: 50%; top: calc(var(--sat) + 62px); transform: translateX(-50%); z-index: 80; height: 40px; padding: 0 16px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; opacity: 0; transition: opacity .3s, transform .4s var(--spring); pointer-events: none; white-space: nowrap; }
.toastp.on { opacity: 1; transform: translateX(-50%) translateY(4px); }
/* ---------- You ---------- */
.you-h { display: flex; gap: 14px; align-items: center; padding: 6px 20px 4px; }
.you-h h2 { margin: 0; font: 700 22px/1.2 var(--display); }
.concept { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: 12px; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--gold2); background: rgba(233,196,106,.10); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.4); }
.stats4 { display: grid; grid-template-columns: repeat(4, 1fr); margin: 14px 16px 4px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); }
.stats4 div { padding: 12px 8px; text-align: center; font-size: 11.5px; color: var(--text3); }
.stats4 b { display: block; font: 700 21px/1.1 var(--display); color: var(--text); font-variant-numeric: tabular-nums; }
.grp { margin: 0 16px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); overflow: hidden; }
.row2 { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 52px; border-top: .5px solid var(--sep); font-size: 15px; }
.row2:first-child { border-top: 0; }
.row2 .ri { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 22%, transparent); color: var(--c, var(--sky)); flex: none; }
.row2 .ri .ic { width: 18px; height: 18px; }
.row2 .rt { flex: 1; min-width: 0; } .row2 .rt small { display: block; font-size: 12px; color: var(--text3); margin-top: 1px; }
.row2 .rv { color: var(--text3); font-size: 14px; font-variant-numeric: tabular-nums; }
.sw2 { width: 51px; height: 31px; border-radius: 16px; background: rgba(120,120,128,.32); position: relative; flex: none; transition: background .25s; }
.sw2::after { content: ""; position: absolute; left: 2px; top: 2px; width: 27px; height: 27px; border-radius: 50%; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.3); transition: transform .3s var(--spring); }
.sw2.on { background: var(--ok); } .sw2.on::after { transform: translateX(20px); }
.capgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px; }
.capc { border-radius: 16px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; }
.capc .im { aspect-ratio: 4/3; background: #111 center/cover; position: relative; }
.capc .im .seal { position: absolute; right: 8px; top: 8px; }
.capc .cm { padding: 8px 10px 10px; font-size: 12px; color: var(--text3); }
.capc .cm b { display: block; color: var(--text); font-size: 13px; font-weight: 600; margin-bottom: 2px; }
.capc .cm .mono { font-size: 10.5px; }
.radius { padding: 14px; }
.radius input[type=range] { width: 100%; accent-color: var(--sky); }
.radius svg { width: 100%; height: 120px; display: block; border-radius: 12px; background: #070A10; }
.credits { padding: 14px; font-size: 12.5px; line-height: 1.5; color: var(--text2); }
.credits b { color: var(--text); }
.inspector { position: absolute; inset: 0; z-index: 75; display: none; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.45); }
.inspector.on { display: flex; animation: viewIn .3s var(--out); }
.inspector .panel { width: 100%; max-width: 560px; border-radius: 30px 30px 0 0; padding: 10px 18px calc(var(--sab) + 18px); max-height: 88%; overflow: auto; }
/* search */
.search { position: absolute; inset: 0; z-index: 65; display: none; flex-direction: column; background: rgba(3,5,8,.5); -webkit-backdrop-filter: blur(28px) saturate(1.3); backdrop-filter: blur(28px) saturate(1.3); }
.search.on { display: flex; animation: viewIn .3s var(--out); }
.search .res { flex: 1; overflow: auto; padding: calc(var(--sat) + 16px) 16px 16px; display: flex; flex-direction: column; justify-content: flex-end; }
.search .grpH { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 14px 4px 6px; }
.search .it { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 14px; font-size: 15px; }
.search .it:active { background: rgba(255,255,255,.06); }
.search .it .ri { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 18%, transparent); color: var(--c, var(--sky)); flex: none; }
.search .it small { display: block; color: var(--text3); font-size: 12px; }
.search .bar { display: flex; gap: 10px; padding: 10px 12px calc(var(--sab) + 12px); }
.search .field { flex: 1; height: 52px; border-radius: 26px; display: flex; align-items: center; gap: 10px; padding: 0 16px; }
.search .field input { flex: 1; background: none; border: 0; outline: 0; font-size: 17px; color: var(--text); min-width: 0; }
/* cold open */
.cold { position: absolute; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding-bottom: calc(var(--sab) + 18vh); pointer-events: auto; transition: opacity 1s ease; }
.cold.out { opacity: 0; pointer-events: none; }
.cold .tag { font: 600 15px/1.4 var(--display); letter-spacing: .32em; text-transform: uppercase; color: rgba(255,255,255,.9); opacity: 0; transform: translateY(8px); transition: all 1.4s var(--out); text-align: center; padding: 0 20px; }
.cold .word { font: 700 44px/1 var(--display); letter-spacing: -.01em; margin-bottom: 14px; opacity: 0; transition: opacity 1.6s ease, letter-spacing 2.4s var(--out); letter-spacing: .12em; }
.cold.s1 .tag { opacity: 1; transform: none; }
.cold.s2 .word { opacity: 1; letter-spacing: -.01em; }
.cold .skip { position: absolute; top: calc(var(--sat) + 12px); right: 14px; height: 34px; padding: 0 14px; border-radius: 17px; font-size: 13px; font-weight: 600; color: var(--text2); }
.cold .eye { width: 60px; height: 60px; margin-bottom: 18px; opacity: 0; transition: opacity 1.2s; color: #fff; }
.cold.s2 .eye { opacity: .95; }
.concept-tag { position: absolute; z-index: 90; left: 14px; top: calc(var(--sat) + 58px); pointer-events: none; font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: rgba(246,223,160,.72); padding: 3px 7px; border-radius: 6px; background: rgba(10,12,18,.35); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.3); }
.app[data-tab="now"] .concept-tag, .app[data-tab="threads"] .concept-tag, .app[data-tab="you"] .concept-tag { top: calc(var(--sat) + 14px); left: auto; right: 14px; }
.app[data-tab="capture"] .concept-tag { top: calc(var(--sat) + 100px); left: 14px; }
.app[data-mode="situation"] .concept-tag { left: auto; right: 14px; top: calc(var(--sat) + 62px); }
/* intro */
.pane, .tabwrap, .acc-host, .concept-tag, .gchrome { transition: opacity .9s ease, transform .45s var(--out); }
.app.intro .pane, .app.intro .tabwrap, .app.intro .acc-host, .app.intro .concept-tag, .app.intro .gchrome, .app.intro .labels { opacity: 0 !important; pointer-events: none !important; }
.app.intro .pane * { pointer-events: none !important; }
/* tab visibility (phone) */
.app[data-tab="now"] .gchrome, .app[data-tab="threads"] .gchrome, .app[data-tab="you"] .gchrome, .app[data-tab="capture"] .gchrome { display: none; }
.app[data-mode="situation"] .gbottom .dock, .app[data-mode="ride"] .gchrome, .app[data-mode="ride"] .tabwrap, .app[data-mode="ride"] .acc-host { display: none; }
.app[data-tab="globe"] .acc-host { display: none; }
.app[data-mode="situation"] .gtop, .app[data-mode="workspace"] .gtop { opacity: 0; pointer-events: none; }
.app[data-mode="situation"] .gtop > * { pointer-events: none; }
.app[data-mode="situation"] .acc-host { display: none; }
.wide-only { display: none !important; }
/* ---------- wide / foldable: two panes ---------- */
@media (min-width: 600px) {
:root { --paneW: clamp(360px, 40vw, 440px); }
.pane { width: var(--paneW); background: var(--bg); border-right: .5px solid var(--sep); z-index: 22; }
.tabwrap { --tabW: var(--paneW); }
.acc-host { right: auto; width: calc(var(--paneW) - 24px); }
.app[data-tab="globe"] .acc-host { display: flex; }
.wide-only { display: block !important; }
.app .gchrome { display: block !important; }
.app .gchrome.flexy { display: flex !important; }
.now-hero { height: calc(var(--sat) + 150px); }
.now-hero::after { display: none; }
.now-hero .lt { text-shadow: none; }
.gbottom { bottom: 18px; padding: 0 16px; max-width: 760px; margin: 0 auto; left: var(--paneW); }
.sheet { left: 0; width: var(--paneW); height: 100%; border-radius: 0; transform: translateX(-104%); z-index: 34; background: var(--bg) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: inset -.5px 0 0 var(--sep) !important; }
.sheet::after { display: none; }
.sheet.wopen { transform: none !important; }
.sheet .grab { display: none; }
.sheet .sc { padding-top: calc(var(--sat) + 60px); }
.backbtn { left: 12px; }
.app[data-mode="situation"] .tabwrap { display: none; }
.ws .body { top: 0; border-radius: 0; background: var(--bg) !important; backdrop-filter: none !important; }
.ws .body::after { display: none; }
.ws .body { padding-top: calc(var(--sat) + 60px); }
.cc { left: var(--paneW); }
#v-capture { z-index: 45; }
.ride { left: var(--paneW); }
.concept-tag, .app[data-tab] .concept-tag { top: calc(var(--sat) + 14px); bottom: auto; left: auto; right: 70px; }
.composer { left: 10px; right: 10px; }
}
@media (min-width: 1100px) {
:root { --paneW: 440px; }
.masonry { grid-template-columns: 1fr 1fr; }
}
/* hinge-aware: vertical fold — panes split exactly at the hinge */
@media (horizontal-viewport-segments: 2) {
:root { --paneW: env(viewport-segment-right 0 0, 50vw); }
#gl, .globe-area { left: env(viewport-segment-left 1 0, 50vw); width: auto; right: 0; }
#gl { width: calc(100% - env(viewport-segment-left 1 0, 50vw)); }
.gtop, .gbottom, .cc, .ride { left: env(viewport-segment-left 1 0, 50vw); }
}
/* tabletop: globe on the top half, controls on the bottom half */
@media (vertical-viewport-segments: 2) {
:root { --paneW: 0px; --fold: env(viewport-segment-bottom 0 0, 50vh); --fold2: env(viewport-segment-top 0 1, 50vh); }
#gl { height: var(--fold); }
.pane { top: var(--fold2); width: 100%; border-right: 0; }
.now-hero { display: none; }
.gbottom { bottom: auto; top: calc(var(--fold2) + 12px); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .12s !important; }
}
+165
View File
@@ -0,0 +1,165 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="claude:viewport-fit-sides" content="cover">
<title>God's Eye</title>
<!--@CSS-->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<linearGradient id="gGold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FBE7A9"/><stop offset=".5" stop-color="#E2B654"/><stop offset="1" stop-color="#A97A1F"/></linearGradient>
</defs>
<symbol id="i-radiowaves" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6M15.8 8.2a5.4 5.4 0 0 1 0 7.6M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17M12 3c-2.6 2.6-3.6 5.6-3.6 9s1 6.4 3.6 9M12 3c2.6 2.6 3.6 5.6 3.6 9s-1 6.4-3.6 9"/></symbol>
<symbol id="i-viewfinder" viewBox="0 0 24 24"><path d="M3.5 8V5.5a2 2 0 0 1 2-2H8M16 3.5h2.5a2 2 0 0 1 2 2V8M20.5 16v2.5a2 2 0 0 1-2 2H16M8 20.5H5.5a2 2 0 0 1-2-2V16"/><circle cx="12" cy="12" r="3.2"/></symbol>
<symbol id="i-threads" viewBox="0 0 24 24"><circle cx="5.5" cy="17.5" r="2.3"/><circle cx="18.5" cy="17.5" r="2.3"/><circle cx="12" cy="5.5" r="2.3"/><path d="M7.9 17.5h8.2M6.7 15.4l4.1-7.8M17.3 15.4l-4.1-7.8" stroke-dasharray="1.6 2.2"/></symbol>
<symbol id="i-person" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3.2"/><path d="M5.9 18.6c1.3-2.4 3.4-3.6 6.1-3.6s4.8 1.2 6.1 3.6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.3"/><path d="m15.3 15.3 5 5"/></symbol>
<symbol id="i-chevl" viewBox="0 0 24 24"><path d="M14.5 5 7.5 12l7 7"/></symbol>
<symbol id="i-chevr" viewBox="0 0 24 24"><path d="m9.5 5 7 7-7 7"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m5 9.5 7 7 7-7"/></symbol>
<symbol id="i-chevu" viewBox="0 0 24 24"><path d="m5 14.5 7-7 7 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5"/></symbol>
<symbol id="i-airplane" viewBox="0 0 24 24"><path d="M12 2.8c.9 0 1.4.9 1.4 2v4.6l7.1 4.2v1.9l-7.1-2.1v4.4l2.2 1.7v1.6L12 20.2l-3.6.9v-1.6l2.2-1.7v-4.4l-7.1 2.1v-1.9l7.1-4.2V4.8c0-1.1.5-2 1.4-2Z"/></symbol>
<symbol id="i-jet" viewBox="0 0 24 24"><path d="M12 2.5 13.6 8l6.4 6v1.6l-6.6-2.3-.6 4.2 2.4 2v1.5L12 20l-3.2 1v-1.5l2.4-2-.6-4.2L4 15.6V14l6.4-6Z"/></symbol>
<symbol id="i-helicopter" viewBox="0 0 24 24"><path d="M3 5.5h14M10 5.5V8"/><path d="M5.5 10.5h7.2a4.3 4.3 0 0 1 0 8.6H9.4a3.9 3.9 0 0 1-3.9-3.9Z"/><path d="M12.7 12.5H17l3-3.5M8 19.1v1.4M13 19.1v1.4M6 20.5h9"/></symbol>
<symbol id="i-propeller" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.8"/><path d="M12 10.2C11 6 11.3 3.5 12 3.5s1 2.5 0 6.7ZM13.6 12.9c3.2 2.9 4.2 5.2 3.6 5.6s-2.6-1.3-4.3-5.2ZM10.4 12.9c-3.2 2.9-4.2 5.2-3.6 5.6s2.6-1.3 4.3-5.2Z"/></symbol>
<symbol id="i-exclamation" viewBox="0 0 24 24"><path d="M12 3.5 21.5 20h-19Z"/><path d="M12 9.5v5M12 17.4v.1"/></symbol>
<symbol id="i-drone" viewBox="0 0 24 24"><circle cx="5.5" cy="5.5" r="2.5"/><circle cx="18.5" cy="5.5" r="2.5"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/><path d="m7.3 7.3 3 3M16.7 7.3l-3 3M7.3 16.7l3-3M16.7 16.7l-3-3"/><rect x="9.8" y="9.8" width="4.4" height="4.4" rx="1"/></symbol>
<symbol id="i-board" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="13" rx="2"/><path d="M6.5 8.5h5M6.5 11.5h7M6.5 14.5h4M16 8.5h1.5M16 11.5h1.5M16 14.5h1.5M9 20.5h6"/></symbol>
<symbol id="i-loop" viewBox="0 0 24 24"><rect x="3.5" y="7" width="17" height="10" rx="5"/><path d="m14 4.5 2.5 2.5L14 9.5"/></symbol>
<symbol id="i-ferry" viewBox="0 0 24 24"><path d="M4 14.5h16l-2.2 4.2H6.2Z"/><path d="M6.5 14.5V10h11v4.5M9 10V7h6v3M3 21c1.5 0 1.5-.8 3-.8s1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8"/></symbol>
<symbol id="i-anchor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><path d="M12 7v13.5M8 10.5h8M4.5 13.5c0 3.8 3.4 7 7.5 7s7.5-3.2 7.5-7M4.5 13.5l-1.3 1.6M19.5 13.5l1.3 1.6"/></symbol>
<symbol id="i-drop" viewBox="0 0 24 24"><path d="M12 3.2s6 6.6 6 11a6 6 0 0 1-12 0c0-4.4 6-11 6-11Z"/><path d="M9.2 15.2a2.9 2.9 0 0 0 2.6 2.6"/></symbol>
<symbol id="i-fish" viewBox="0 0 24 24"><path d="M3 12c3-4.2 7.5-5.7 11.5-4.4 2 .7 3.6 2.2 4.5 4.4-.9 2.2-2.5 3.7-4.5 4.4C10.5 17.7 6 16.2 3 12Z"/><path d="M19 12l2.5-3v6Z"/><circle cx="14.5" cy="11" r=".6" fill="currentColor"/></symbol>
<symbol id="i-eyeslash" viewBox="0 0 24 24"><path d="M2.8 12S6.2 5.8 12 5.8 21.2 12 21.2 12s-3.4 6.2-9.2 6.2S2.8 12 2.8 12Z"/><circle cx="12" cy="12" r="2.8"/><path d="M4 20 20 4"/></symbol>
<symbol id="i-cable" viewBox="0 0 24 24"><path d="M3 15c3 0 3-6 6-6s3 6 6 6 3-6 6-6"/><circle cx="3" cy="15" r="1.3" fill="currentColor"/><circle cx="21" cy="9" r="1.3" fill="currentColor"/></symbol>
<symbol id="i-satellite" viewBox="0 0 24 24"><rect x="9.5" y="9.5" width="5" height="5" rx="1" transform="rotate(45 12 12)"/><path d="m8.4 8.4-3-3M15.6 15.6l3 3"/><rect x="1.8" y="2.8" width="5" height="3" rx=".6" transform="rotate(45 4.3 4.3)"/><rect x="17.2" y="18.2" width="5" height="3" rx=".6" transform="rotate(45 19.7 19.7)"/><path d="M15 6.5a3.5 3.5 0 0 1 2.5 2.5M15.5 3.6a6.4 6.4 0 0 1 4.9 4.9"/></symbol>
<symbol id="i-iss" viewBox="0 0 24 24"><path d="M3 12h18M12 9v6"/><rect x="2.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="2.5" y="14" width="4" height="4.5" rx=".6"/><rect x="17.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="17.5" y="14" width="4" height="4.5" rx=".6"/><rect x="9.5" y="10" width="5" height="4" rx="1"/></symbol>
<symbol id="i-train" viewBox="0 0 24 24"><circle cx="4" cy="16" r="1.3"/><circle cx="8" cy="13.6" r="1.3"/><circle cx="12" cy="11.3" r="1.3"/><circle cx="16" cy="9" r="1.3"/><circle cx="20" cy="6.7" r="1.3"/></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 2.8c2.6 2.2 3.8 5.2 3.8 8.6v4.4H8.2v-4.4c0-3.4 1.2-6.4 3.8-8.6Z"/><circle cx="12" cy="9.5" r="1.4"/><path d="M8.2 12.5 5.5 15v3.2l2.7-1.6M15.8 12.5l2.7 2.5v3.2l-2.7-1.6M10.2 18.5c.3 1.3.9 2.2 1.8 2.8.9-.6 1.5-1.5 1.8-2.8"/></symbol>
<symbol id="i-debris" viewBox="0 0 24 24"><path d="m4 7 3-2 1 3ZM15 4l4 1-2 3ZM10 12l3-1 1 3-3 1ZM4 17l3 .5-1 2.5ZM17 16l3 1-1 3-3-1Z"/></symbol>
<symbol id="i-orbitpath" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="9.5" ry="4.2" transform="rotate(-25 12 12)" stroke-dasharray="2 2.4"/><circle cx="12" cy="12" r="3.2"/><circle cx="19.3" cy="8.4" r="1.5" fill="currentColor"/></symbol>
<symbol id="i-wave" viewBox="0 0 24 24"><path d="M2.5 12h3.2l1.6-4 2.4 9.5 2.8-14 2.6 13 1.8-6.5 1.4 2H21.5"/></symbol>
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M12 21c-3.9 0-6.5-2.6-6.5-6.2 0-4.3 3.6-6 4.4-10.8 2.6 1.7 4.1 4.2 4.1 6.6 1-.6 1.6-1.6 1.9-2.8 1.7 1.6 2.6 4 2.6 6.6 0 3.9-2.6 6.6-6.5 6.6Z"/><path d="M12 21c-1.7 0-2.8-1.2-2.8-2.8 0-1.9 1.6-2.6 2-4.6 1.6 1 2.6 2.6 2.6 4.4 0 1.8-.6 3-1.8 3Z"/></symbol>
<symbol id="i-perimeter" viewBox="0 0 24 24"><path d="M5 9.5 9 4.5l7 1.5 3.5 6-2.5 6.5-7 1L4.5 15Z" stroke-dasharray="2.2 2"/><circle cx="11.5" cy="12" r="2" /></symbol>
<symbol id="i-hurricane" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4"/><path d="M12 4.2c-4.8 0-8 3.2-8.3 6.2 2-2.2 4.4-3 6.5-2.6M12 19.8c4.8 0 8-3.2 8.3-6.2-2 2.2-4.4 3-6.5 2.6M9.6 12a7.6 7.6 0 0 0 8-7M14.4 12a7.6 7.6 0 0 0-8 7"/></symbol>
<symbol id="i-mountain" viewBox="0 0 24 24"><path d="M2.5 19.5 9.5 7l3.6 6.2 2.4-3.5 6 9.8Z"/><path d="m7.4 10.8 2.1 1.6 1.7-1.3"/></symbol>
<symbol id="i-aqi" viewBox="0 0 24 24"><path d="M3 9h11a3 3 0 1 0-3-3M3 14h15a3 3 0 1 1-3 3M3 19h7"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><path d="M3.5 8.2 15.6 5l1.3 4.8L4.8 13Z"/><path d="m16.2 7.4 3.8-1.2v4.4l-3.1.8M8 12.5l1 4H5.5M3 14.5v4"/></symbol>
<symbol id="i-seal" viewBox="0 0 24 24"><path fill="url(#gGold)" stroke="none" d="M12 1.6l2.1 1.6 2.6-.4 1 2.4 2.4 1-.4 2.6 1.6 2.1-1.6 2.1.4 2.6-2.4 1-1 2.4-2.6-.4L12 22.4l-2.1-1.6-2.6.4-1-2.4-2.4-1 .4-2.6L2.7 13l1.6-2.1-.4-2.6 2.4-1 1-2.4 2.6.4Z"/><circle cx="12" cy="12" r="6.2" fill="none" stroke="#7A5512" stroke-width=".8" opacity=".55"/><path d="m8.8 12.2 2.2 2.2 4.2-4.6" fill="none" stroke="#3B2A08" stroke-width="1.8"/></symbol>
<symbol id="i-binoculars" viewBox="0 0 24 24"><circle cx="6.5" cy="15.5" r="3.5"/><circle cx="17.5" cy="15.5" r="3.5"/><path d="M10 15h4M3.2 14 5 6.5a2 2 0 0 1 3.9.4V12M20.8 14 19 6.5a2 2 0 0 0-3.9.4V12"/></symbol>
<symbol id="i-cube" viewBox="0 0 24 24"><path d="M12 2.8 20 7.2v9.6L12 21.2 4 16.8V7.2Z"/><path d="M4 7.2 12 11.6l8-4.4M12 11.6v9.6"/></symbol>
<symbol id="i-live" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4"/><rect x="2.5" y="4.5" width="19" height="15" rx="3"/></symbol>
<symbol id="i-tram" viewBox="0 0 24 24"><rect x="5.5" y="5" width="13" height="12.5" rx="3"/><path d="M5.5 11.5h13M9 2.8h6M12 2.8V5M8 17.5 6.5 21M16 17.5l1.5 3.5"/><circle cx="9" cy="14.5" r=".8" fill="currentColor"/><circle cx="15" cy="14.5" r=".8" fill="currentColor"/></symbol>
<symbol id="i-car" viewBox="0 0 24 24"><path d="M4 15.5V12l2-4.8a2 2 0 0 1 1.8-1.2h8.4a2 2 0 0 1 1.8 1.2L20 12v3.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1Z"/><path d="M4 12h16M6.5 16.5v2M17.5 16.5v2"/><circle cx="7.5" cy="14" r=".8" fill="currentColor"/><circle cx="16.5" cy="14" r=".8" fill="currentColor"/></symbol>
<symbol id="i-bike" viewBox="0 0 24 24"><circle cx="6" cy="15.5" r="3.6"/><circle cx="18" cy="15.5" r="3.6"/><path d="m6 15.5 4-7h6l2 7M10 8.5l2.8 7h-6.8M9 6h2.5M15 5.5h2.5l-1.5 3"/></symbol>
<symbol id="i-rail" viewBox="0 0 24 24"><path d="M8 3 5 21M16 3l3 18M7.3 7.5h9.4M6.6 12h10.8M5.8 16.5h12.4"/></symbol>
<symbol id="i-ticket" viewBox="0 0 24 24"><path d="M3.5 7.5a1 1 0 0 1 1-1h15a1 1 0 0 1 1 1v2.3a2.2 2.2 0 0 0 0 4.4v2.3a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-2.3a2.2 2.2 0 0 0 0-4.4Z"/><path d="M14.5 6.5v11" stroke-dasharray="1.5 1.8"/></symbol>
<symbol id="i-plate" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="2"/><path d="M6 12h2M10 12h4M16 12h2"/></symbol>
<symbol id="i-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><path d="M12 12 18.4 5.6"/><circle cx="15.5" cy="9.2" r="1" fill="currentColor"/></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 18.5a4.2 4.2 0 0 1-.6-8.4A5.8 5.8 0 0 1 17.6 9a4.8 4.8 0 0 1-.6 9.5Z"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13.5 2.8 5 13.6h6.2L10.3 21.2l8.7-11h-6.3Z"/></symbol>
<symbol id="i-wind" viewBox="0 0 24 24"><path d="M3 9h11.5a2.8 2.8 0 1 0-2.8-2.8M3 13h16a2.8 2.8 0 1 1-2.8 2.8M3 17h7"/></symbol>
<symbol id="i-spaghetti" viewBox="0 0 24 24"><path d="M3 18C8 17 12 13 14 6M3 18c6-.5 10-3 15-9M3 18c6 0 11-1.5 17-6"/><circle cx="3" cy="18" r="1.4" fill="currentColor"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect x="4" y="3.5" width="16" height="7" rx="1.6"/><rect x="4" y="13.5" width="16" height="7" rx="1.6"/><path d="M7.5 7h.1M7.5 17h.1M11 7h5.5M11 17h5.5"/></symbol>
<symbol id="i-dam" viewBox="0 0 24 24"><path d="M3 5.5h6l2 13H3ZM11 18.5h10M13 12c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9M13 15.5c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 2.8 19.5 5.6v5.6c0 4.6-3 8.4-7.5 10-4.5-1.6-7.5-5.4-7.5-10V5.6Z"/><path d="M12 7.5v9M8 11.5h8"/></symbol>
<symbol id="i-jam" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M8 8a5.6 5.6 0 0 0 0 8M16 8a5.6 5.6 0 0 1 0 8M5.2 5.2a9.6 9.6 0 0 0 0 13.6M18.8 5.2a9.6 9.6 0 0 1 0 13.6M3.5 20.5l17-17"/></symbol>
<symbol id="i-frame" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2"/><path d="m3.5 15.5 5-4.5 4 3.5 3-2.5 5 4"/><circle cx="16" cy="9" r="1.5"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></symbol>
<symbol id="i-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m12 4.8 2.6 7.2H9.4Z" fill="#FF6A3D" stroke="none"/><path d="m12 19.2-2.6-7.2h5.2Z" fill="currentColor" stroke="none" opacity=".55"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="M12 3.5 21 8l-9 4.5L3 8Z"/><path d="m3 12 9 4.5 9-4.5M3 16l9 4.5 9-4.5"/></symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M10 3.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6ZM18 13.5c.3 1.9 1.1 2.7 3 3-1.9.3-2.7 1.1-3 3-.3-1.9-1.1-2.7-3-3 1.9-.3 2.7-1.1 3-3Z"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.6v14.8a.8.8 0 0 0 1.2.7l12-7.4a.8.8 0 0 0 0-1.4l-12-7.4A.8.8 0 0 0 7 4.6Z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="6" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/><rect x="14" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.6 2H4.4Z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/></symbol>
<symbol id="i-bellplus" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 8-5.7M18 12v4.5l1.6 2H4.4L6 16.5"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0M18.5 3v6M15.5 6h6"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><path d="M12 14.5V3M8 6.8 12 3l4 3.8M7 10H5.5v10.5h13V10H17"/></symbol>
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
<symbol id="i-heartf" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
<symbol id="i-bubble" viewBox="0 0 24 24"><path d="M4 11.5c0-4 3.6-7 8-7s8 3 8 7-3.6 7-8 7c-1 0-1.9-.1-2.8-.4L5 19.8l1.1-3.4C4.8 15.1 4 13.4 4 11.5Z"/></symbol>
<symbol id="i-location" viewBox="0 0 24 24"><path d="M20.5 3.5 3.5 10.6l7.3 2.6 2.6 7.3Z"/></symbol>
<symbol id="i-home" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.2"/><path d="M8.2 10.5V7.8a3.8 3.8 0 0 1 7.6 0v2.7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
<symbol id="i-hash" viewBox="0 0 24 24"><path d="M9.5 3.5 7.5 20.5M16.5 3.5l-2 17M4.5 8.5h16M3.5 15.5h16"/></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="M3.5 16a8.5 8.5 0 1 1 17 0"/><path d="m12 16 4.5-5.5"/><circle cx="12" cy="16" r="1.3" fill="currentColor"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.3 2"/></symbol>
<symbol id="i-wand" viewBox="0 0 24 24"><path d="m4 20 11-11M13.5 7.5l3 3"/><path d="M18 2.8v3M16.5 4.3h3M20.5 8.5v2M19.5 9.5h2M10.5 3v2M9.5 4h2"/></symbol>
<symbol id="i-signal" viewBox="0 0 24 24"><path d="M4 19.5v-3M9 19.5v-6M14 19.5v-9M19 19.5v-13"/></symbol>
<symbol id="i-arrowup" viewBox="0 0 24 24"><path d="M12 19.5v-15M6 10.5l6-6 6 6"/></symbol>
<symbol id="i-thumbup" viewBox="0 0 24 24"><path d="M7.5 10.5v9.5H4.5v-9.5ZM7.5 10.5l3.6-6.6a1.8 1.8 0 0 1 3.3 1.3l-.9 4.3h5.2a1.8 1.8 0 0 1 1.8 2.1l-1.2 6.6a2.2 2.2 0 0 1-2.2 1.8H7.5"/></symbol>
<symbol id="i-thumbdown" viewBox="0 0 24 24"><path d="M7.5 13.5V4H4.5v9.5ZM7.5 13.5l3.6 6.6a1.8 1.8 0 0 0 3.3-1.3l-.9-4.3h5.2a1.8 1.8 0 0 0 1.8-2.1L19.3 5.8A2.2 2.2 0 0 0 17.1 4H7.5"/></symbol>
<symbol id="i-gyro" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><ellipse cx="12" cy="12" rx="8.5" ry="3.2"/><ellipse cx="12" cy="12" rx="3.2" ry="8.5"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/><path d="M9.5 3v3.5M14.5 3v3.5M9.5 17.5V21M14.5 17.5V21M3 9.5h3.5M3 14.5h3.5M17.5 9.5H21M17.5 14.5H21"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="14.5" r="4"/><path d="m10.4 11.6 9.1-9.1M16.5 5.5l2.5 2.5M14 8l2 2"/></symbol>
<symbol id="i-flip" viewBox="0 0 24 24"><path d="M4 8.5h13.5l-3-3M20 15.5H6.5l3 3"/></symbol>
<symbol id="i-gallery" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="m3.5 15 4.5-4 4 3.5 3-2.5 5.5 4.5"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4M5 4.5h12l-2.5 4 2.5 4H5"/></symbol>
<symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19c.7-3 2.8-4.6 5.5-4.6s4.8 1.6 5.5 4.6"/><circle cx="16.5" cy="9" r="2.4"/><path d="M15.2 14.6c2.6-.3 4.6 1 5.3 3.9"/></symbol>
</svg>
<div id="app" class="app" data-tab="now" data-mode="">
<canvas id="gl" aria-label="Interactive 3D globe"></canvas>
<div class="globe-area"><div id="labels" class="labels"></div></div>
<div class="pane">
<section id="v-now" class="view on" aria-label="Now"></section>
<section id="v-threads" class="view" aria-label="Threads"></section>
<section id="v-you" class="view" aria-label="You"></section>
<section id="v-layers" class="view wide-only-view" aria-label="Layers"></section>
</div>
<!-- globe chrome -->
<div class="gtop gchrome flexy">
<button class="status glass" id="status"><span class="dot pulse"></span><span id="statusTxt">Live</span><span class="t num" id="statusTime"></span></button>
<div class="gbtns">
<button class="gbtn glass compass" id="btnCompass" aria-label="Reset north"><svg class="ic"><use href="#i-compass"/></svg></button>
<button class="gbtn glass" id="btnLayers" aria-label="Layers"><svg class="ic"><use href="#i-layers"/></svg></button>
<button class="gbtn glass" id="btnLens" aria-label="Lens"><svg class="ic"><use href="#i-sparkles"/></svg></button>
<button class="gbtn glass" id="btnHome" aria-label="Planet view"><svg class="ic"><use href="#i-globe"/></svg></button>
</div>
</div>
<div class="gbottom gchrome flexy" id="gbottom">
<div class="pick glass strong" id="pick"></div>
<div class="dock" id="dock"></div>
<div class="ribbon glass" id="ribbon"><canvas></canvas><div class="head"></div><div class="readout"><span id="rbTime" class="num"></span><span id="rbRel" class="rel num"></span></div><span class="fc">FORECAST</span><button class="nowbtn live" id="rbNow"><span class="dot"></span>NOW</button></div>
</div>
<button class="backbtn circ glass" id="back" aria-label="Back"><svg class="ic"><use href="#i-chevl"/></svg></button>
<div class="sitbtns" id="sitbtns"><button class="circ glass" id="sitFollow" aria-label="Follow"><svg class="ic"><use href="#i-bellplus"/></svg></button><button class="circ glass" id="sitShare" aria-label="Share"><svg class="ic"><use href="#i-share"/></svg></button></div>
<div class="sheet glass strong" id="sheet"><div class="grab" id="grab"></div><div class="sc" id="sheetC"></div></div>
<div class="ride" id="ride"></div>
<section id="v-capture" class="view" aria-label="Capture"></section>
<div class="cc" id="cc"></div>
<div class="search" id="search"></div>
<div class="acc-host" id="accHost"><button class="accessory glass" id="acc"></button></div>
<nav class="tabwrap" id="tabwrap">
<div class="tabbar glass" id="tabbar" role="tablist">
<div class="tablens" id="tablens"></div>
<button class="tab on" data-tab="now" role="tab"><svg class="ic"><use href="#i-radiowaves"/></svg><span>Now</span></button>
<button class="tab" data-tab="globe" role="tab"><svg class="ic"><use href="#i-globe"/></svg><span>Globe</span></button>
<button class="tab" data-tab="capture" role="tab"><svg class="ic"><use href="#i-viewfinder"/></svg><span>Capture</span></button>
<button class="tab" data-tab="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Threads</span></button>
<button class="tab" data-tab="you" role="tab"><svg class="ic"><use href="#i-person"/></svg><span>You</span></button>
</div>
<button class="accessory glass" id="accMini"></button>
<button class="searchbtn glass" id="btnSearch" aria-label="Search"><svg class="ic"><use href="#i-search"/></svg></button>
</nav>
<div class="morph" id="morph"></div>
<div class="seal-ov" id="sealOv"></div>
<div class="inspector" id="inspector"></div>
<div class="toastp glass" id="toast"></div>
<div class="concept-tag">Concept · illustrative data</div>
<div class="cold" id="cold">
<button class="skip glass" id="skip">Skip</button>
<svg class="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/></svg>
<div class="word">God’s Eye</div>
<div class="tag">The window to the world.</div>
</div>
</div>
<!--@TEX-->
<!--@JS-->
+240
View File
@@ -0,0 +1,240 @@
/* God's Eye — vision mockup. Utilities: math, geo, sun, rng, DOM, formatting. */
const GE = (window.GE = window.GE || {});
const D2R = Math.PI / 180, R2D = 180 / Math.PI, TAU = Math.PI * 2;
const EARTH_KM = 6371.0, EARTH_M = 6371000.0;
const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const ease = {
inOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
out: (t) => 1 - Math.pow(1 - t, 3),
outQuint: (t) => 1 - Math.pow(1 - t, 5),
inOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
outBack: (t) => { const c1 = 1.4, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); },
};
const prefersReduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
/* ---------- vec3 / mat4 (Float64 arrays for CPU precision) ---------- */
const v3 = {
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
len: (a) => Math.hypot(a[0], a[1], a[2]),
norm: (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; },
lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t],
madd: (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s],
};
const m4 = {
ident: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
mul(a, b) { // column-major a*b
const o = new Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++)
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
return o;
},
persp(fovy, aspect, n, f) {
const t = 1 / Math.tan(fovy / 2);
return [t / aspect, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, (2 * f * n) / (n - f), 0];
},
// rotation-only view from basis (right, up, back) rows
viewRot(right, up, back) {
return [right[0], up[0], back[0], 0, right[1], up[1], back[1], 0, right[2], up[2], back[2], 0, 0, 0, 0, 1];
},
invert(m) {
const a = m, o = new Array(16);
const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3], a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11], a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11;
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30;
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32;
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
if (!det) return m4.ident();
det = 1 / det;
o[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; o[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
o[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; o[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
o[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; o[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
o[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; o[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
o[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; o[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
o[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; o[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
o[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; o[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
o[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; o[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
return o;
},
xform(m, p) { // returns clip vec4
const x = p[0], y = p[1], z = p[2];
return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14], m[3] * x + m[7] * y + m[11] * z + m[15]];
},
};
/* ---------- geo (unit sphere, x→(0,0), y→(0,90E), z→north) ---------- */
const geo = {
vec(lat, lon, altKm = 0) {
const r = 1 + altKm / EARTH_KM, la = lat * D2R, lo = lon * D2R, c = Math.cos(la);
return [r * c * Math.cos(lo), r * c * Math.sin(lo), r * Math.sin(la)];
},
ll(v) { const r = v3.len(v); return [Math.asin(clamp(v[2] / r, -1, 1)) * R2D, Math.atan2(v[1], v[0]) * R2D, (r - 1) * EARTH_KM]; },
enu(lat, lon) { // east, north, up
const la = lat * D2R, lo = lon * D2R;
const up = [Math.cos(la) * Math.cos(lo), Math.cos(la) * Math.sin(lo), Math.sin(la)];
const east = [-Math.sin(lo), Math.cos(lo), 0];
const north = [-Math.sin(la) * Math.cos(lo), -Math.sin(la) * Math.sin(lo), Math.cos(la)];
return { east, north, up };
},
dist(lat1, lon1, lat2, lon2) { // radians
const p1 = lat1 * D2R, p2 = lat2 * D2R, dp = p2 - p1, dl = (lon2 - lon1) * D2R;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * Math.asin(Math.min(1, Math.sqrt(a)));
},
bearing(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R, p2 = lat2 * D2R, dl = (lon2 - lon1) * D2R;
return Math.atan2(Math.sin(dl) * Math.cos(p2), Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl));
},
slerp(a, b, t) {
const d = clamp(v3.dot(a, b), -1, 1), om = Math.acos(d);
if (om < 1e-9) return a.slice();
const s = Math.sin(om);
return v3.add(v3.scale(a, Math.sin((1 - t) * om) / s), v3.scale(b, Math.sin(t * om) / s));
},
// destination from lat/lon, bearing (rad), distance (km)
dest(lat, lon, brg, km) {
const d = km / EARTH_KM, p1 = lat * D2R, l1 = lon * D2R;
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(brg));
const l2 = l1 + Math.atan2(Math.sin(brg) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
return [p2 * R2D, ((l2 * R2D + 540) % 360) - 180];
},
// local ENU meters offset -> lat/lon
offset(lat, lon, eM, nM) { return [lat + (nM / EARTH_M) * R2D, lon + (eM / (EARTH_M * Math.cos(lat * D2R))) * R2D]; },
};
/* ---------- sun & sidereal time ---------- */
const astro = {
jd: (ms) => ms / 86400000 + 2440587.5,
// returns { lat, lon, dir:[x,y,z], gmst(rad) }
sun(ms) {
const d = astro.jd(ms) - 2451545.0;
const g = (357.529 + 0.98560028 * d) * D2R;
const q = 280.459 + 0.98564736 * d;
const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R;
const e = (23.439 - 0.00000036 * d) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
const gmst = ((18.697374558 + 24.06570982441908 * d) % 24) * 15 * D2R;
let lon = (ra - gmst) * R2D;
lon = ((lon % 360) + 540) % 360 - 180;
const lat = dec * R2D;
return { lat, lon, dir: geo.vec(lat, lon), gmst: ((gmst % TAU) + TAU) % TAU };
},
// sun elevation (deg) at a place
elev(ms, lat, lon) { const s = astro.sun(ms); return 90 - geo.dist(lat, lon, s.lat, s.lon) * R2D; },
};
/* ---------- seeded rng ---------- */
function rng(seed) {
let a = seed >>> 0;
const f = () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
f.range = (a0, b0) => a0 + (b0 - a0) * f();
f.int = (n) => Math.floor(f() * n);
f.pick = (arr) => arr[Math.floor(f() * arr.length)];
f.gauss = () => { let u = 0, v = 0; while (!u) u = f(); while (!v) v = f(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v); };
f.wpick = (arr, w) => { let s = 0; for (const x of arr) s += w(x); let r = f() * s; for (const x of arr) { r -= w(x); if (r <= 0) return x; } return arr[arr.length - 1]; };
return f;
}
const hash01 = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
/* ---------- DOM ---------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
function h(tag, attrs, ...kids) {
const e = document.createElement(tag);
if (attrs) for (const k in attrs) {
const v = attrs[k];
if (v == null || v === false) continue;
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v === true ? '' : v);
}
for (const k of kids.flat()) if (k != null && k !== false) e.append(k.nodeType ? k : document.createTextNode(String(k)));
return e;
}
const ic = (name, cls = '') => `<svg class="ic ${cls}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
/* ---------- formatting ---------- */
const fmt = {
n(x, d = 0) { return Number(x).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); },
k(x) { return x >= 1e6 ? (x / 1e6).toFixed(1) + 'M' : x >= 1e4 ? Math.round(x / 1e3) + 'k' : x >= 1e3 ? (x / 1e3).toFixed(1) + 'k' : String(Math.round(x)); },
ago(ms) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 5) return 'now';
if (s < 60) return s + ' s ago';
if (s < 3600) return Math.round(s / 60) + ' min ago';
if (s < 86400) return Math.round(s / 3600) + ' h ago';
return Math.round(s / 86400) + ' d ago';
},
utc(ms, withDay = true) {
const d = new Date(ms);
const dd = d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' });
const tt = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
return withDay ? `${dd} · ${tt} UTC` : tt;
},
hm(ms) { const d = new Date(ms); return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0'); },
rel(h) { // hours offset → "−26 h" / "+4 h 30 m"
const sign = h < 0 ? '−' : '+', a = Math.abs(h), H = Math.floor(a), M = Math.round((a - H) * 60);
if (a < 1 / 60) return 'Live';
return `${sign}${H ? H + ' h' : ''}${M && H < 24 ? (H ? ' ' : '') + M + ' m' : ''}`;
},
ll(lat, lon, d = 3) { return `${Math.abs(lat).toFixed(d)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(d)}°${lon >= 0 ? 'E' : 'W'}`; },
dur(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}`; },
hex: (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join(''),
};
/* ---------- tiny event bus ---------- */
const bus = {
m: new Map(),
on(e, f) { if (!this.m.has(e)) this.m.set(e, new Set()); this.m.get(e).add(f); return () => this.m.get(e).delete(f); },
off(e, f) { this.m.get(e)?.delete(f); },
emit(e, d) { this.m.get(e)?.forEach((f) => { try { f(d); } catch (err) { console.warn(err); } }); },
};
/* ---------- storage (conveniences only) ---------- */
const store = {
get(k, d) { try { const v = localStorage.getItem('ge.v1.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem('ge.v1.' + k, JSON.stringify(v)); } catch {} },
};
const haptic = (p = 8) => { try { navigator.vibrate && navigator.vibrate(p); } catch {} };
/* ---------- hashing ---------- */
// SHA-256 fallback (only used if crypto.subtle is unavailable)
function sha256js(bytes) {
const K = new Uint32Array([0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]);
const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
const l = bytes.length, nb = ((l + 9 + 63) >> 6) << 6, m = new Uint8Array(nb);
m.set(bytes); m[l] = 0x80; const dv = new DataView(m.buffer); dv.setUint32(nb - 4, l * 8); dv.setUint32(nb - 8, Math.floor((l * 8) / 4294967296));
const W = new Uint32Array(64); const r = (x, n) => (x >>> n) | (x << (32 - n));
for (let o = 0; o < nb; o += 64) {
for (let i = 0; i < 16; i++) W[i] = dv.getUint32(o + i * 4);
for (let i = 16; i < 64; i++) { const s0 = r(W[i-15],7)^r(W[i-15],18)^(W[i-15]>>>3), s1 = r(W[i-2],17)^r(W[i-2],19)^(W[i-2]>>>10); W[i] = (W[i-16]+s0+W[i-7]+s1)|0; }
let [a,b,c,d,e,f,g,hh] = H;
for (let i = 0; i < 64; i++) { const S1=r(e,6)^r(e,11)^r(e,25), ch=(e&f)^(~e&g), t1=(hh+S1+ch+K[i]+W[i])|0, S0=r(a,2)^r(a,13)^r(a,22), mj=(a&b)^(a&c)^(b&c), t2=(S0+mj)|0; hh=g; g=f; f=e; e=(d+t1)|0; d=c; c=b; b=a; a=(t1+t2)|0; }
H[0]+=a; H[1]+=b; H[2]+=c; H[3]+=d; H[4]+=e; H[5]+=f; H[6]+=g; H[7]+=hh;
}
const out = new Uint8Array(32); const ov = new DataView(out.buffer); for (let i = 0; i < 8; i++) ov.setUint32(i * 4, H[i]); return out.buffer;
}
const hasSubtle = () => { try { return !!(window.crypto && crypto.subtle && crypto.subtle.digest); } catch { return false; } };
async function sha256(data) {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
if (hasSubtle()) { try { return fmt.hex(await crypto.subtle.digest('SHA-256', bytes)); } catch {} }
return fmt.hex(sha256js(bytes));
}
// canonical JSON (sorted keys) for hashing
function canon(o) {
if (o === null || typeof o !== 'object') return JSON.stringify(o);
if (Array.isArray(o)) return '[' + o.map(canon).join(',') + ']';
return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + canon(o[k])).join(',') + '}';
}
Object.assign(GE, { D2R, R2D, TAU, EARTH_KM, clamp, lerp, smooth, ease, v3, m4, geo, astro, rng, fmt, bus, store, sha256, canon });
+281
View File
@@ -0,0 +1,281 @@
/* Synthetic, plausible world data. Real places & public phenomena; all people, handles, posts,
callsigns and reconstruction claims are illustrative (Concept · illustrative data). */
const NOW = Date.now();
GE.NOW = NOW;
const R = rng(20261018);
/* ---------- airports [iata, city, lat, lon, weight] ---------- */
const AIRPORTS = [
['ATL','Atlanta',33.6407,-84.4277,104],['DXB','Dubai',25.2532,55.3657,87],['DFW','Dallas–Fort Worth',32.8998,-97.0403,82],['LHR','London Heathrow',51.47,-0.4543,80],
['HND','Tokyo Haneda',35.5494,139.7798,79],['DEN','Denver',39.8561,-104.6737,78],['IST','Istanbul',41.2753,28.7519,76],['LAX','Los Angeles',33.9416,-118.4085,75],
['ORD','Chicago O’Hare',41.9742,-87.9073,74],['DEL','Delhi',28.5562,77.1,73],['CDG','Paris CDG',49.0097,2.5479,70],['JFK','New York JFK',40.6413,-73.7781,62],
['CAN','Guangzhou',23.3924,113.2988,64],['PEK','Beijing',40.0799,116.6031,60],['PVG','Shanghai Pudong',31.1443,121.8083,62],['SIN','Singapore',1.3644,103.9915,59],
['AMS','Amsterdam',52.3105,4.7683,62],['FRA','Frankfurt',50.0379,8.5622,60],['MAD','Madrid',40.4983,-3.5676,60],['BKK','Bangkok',13.69,100.7501,58],
['ICN','Seoul Incheon',37.4602,126.4407,56],['BOM','Mumbai',19.0896,72.8656,52],['CGK','Jakarta',-6.1256,106.6559,50],['SFO','San Francisco',37.6213,-122.379,50],
['LAS','Las Vegas',36.084,-115.1537,55],['MCO','Orlando',28.4312,-81.3081,57],['MIA','Miami',25.7959,-80.287,52],['CLT','Charlotte',35.214,-80.9431,53],
['SEA','Seattle',47.4502,-122.3088,50],['PHX','Phoenix',33.4342,-112.0116,48],['EWR','Newark',40.6895,-74.1745,49],['IAH','Houston',29.9902,-95.3368,46],
['BOS','Boston',42.3656,-71.0096,40],['MSP','Minneapolis',44.8848,-93.2223,36],['DTW','Detroit',42.2162,-83.3554,32],['YYZ','Toronto',43.6777,-79.6248,45],
['YVR','Vancouver',49.1967,-123.1815,25],['MEX','Mexico City',19.4361,-99.0719,48],['CUN','Cancún',21.0365,-86.877,30],['GRU','São Paulo',-23.4356,-46.4731,42],
['BOG','Bogotá',4.7016,-74.1469,38],['LIM','Lima',-12.0219,-77.1143,24],['SCL','Santiago',-33.393,-70.7858,24],['EZE','Buenos Aires',-34.8222,-58.5358,12],
['GIG','Rio de Janeiro',-22.809,-43.2506,15],['PTY','Panama City',9.0714,-79.3835,17],['MUC','Munich',48.3537,11.775,41],['FCO','Rome',41.8003,12.2389,40],
['BCN','Barcelona',41.2974,2.0833,52],['LGW','London Gatwick',51.1537,-0.1821,41],['ZRH','Zurich',47.4582,8.5555,31],['VIE','Vienna',48.1103,16.5697,29],
['CPH','Copenhagen',55.618,12.6508,30],['OSL','Oslo',60.1976,11.1004,27],['ARN','Stockholm',59.6498,17.9238,25],['HEL','Helsinki',60.3172,24.9633,18],
['DUB','Dublin',53.4264,-6.2499,33],['LIS','Lisbon',38.7742,-9.1342,33],['ATH','Athens',37.9364,23.9445,28],['WAW','Warsaw',52.1657,20.9671,21],
['PRG','Prague',50.1008,14.26,17],['BRU','Brussels',50.9014,4.4844,24],['MAN','Manchester',53.3537,-2.275,29],['PMI','Palma',39.5517,2.7388,33],
['DOH','Doha',25.2731,51.6081,45],['AUH','Abu Dhabi',24.433,54.6511,28],['RUH','Riyadh',24.9576,46.6988,36],['JED','Jeddah',21.6702,39.1525,42],
['CAI','Cairo',30.1219,31.4056,30],['TLV','Tel Aviv',32.0055,34.8854,24],['ADD','Addis Ababa',8.9779,38.7993,13],['NBO','Nairobi',-1.3192,36.9278,9],
['JNB','Johannesburg',-26.1392,28.246,21],['CPT','Cape Town',-33.9715,18.6021,11],['LOS','Lagos',6.5774,3.3212,8],['CMN','Casablanca',33.3675,-7.5898,10],
['HKG','Hong Kong',22.308,113.9185,53],['TPE','Taipei',25.0797,121.2342,45],['NRT','Tokyo Narita',35.772,140.3929,33],['KIX','Osaka Kansai',34.4347,135.244,28],
['MNL','Manila',14.5086,121.0198,45],['KUL','Kuala Lumpur',2.7456,101.7072,57],['SGN','Ho Chi Minh City',10.8188,106.6519,40],['HAN','Hanoi',21.2187,105.8042,29],
['CTU','Chengdu',30.5785,103.9471,50],['SZX','Shenzhen',22.6393,113.8107,52],['KMG','Kunming',25.1019,102.9292,45],['XIY','Xi’an',34.4471,108.7516,43],
['CKG','Chongqing',29.7192,106.6417,44],['HGH','Hangzhou',30.2295,120.4344,40],['BLR','Bengaluru',13.1986,77.7066,37],['MAA','Chennai',12.9941,80.1709,22],
['CCU','Kolkata',22.6547,88.4467,22],['HYD','Hyderabad',17.2403,78.4294,21],['KTM','Kathmandu',27.6966,85.3591,8],['DAC','Dhaka',23.8433,90.3978,9],
['KHI','Karachi',24.9065,67.1608,8],['SYD','Sydney',-33.9399,151.1753,44],['MEL','Melbourne',-37.669,144.841,37],['BNE','Brisbane',-27.3842,153.1175,24],
['PER','Perth',-31.9385,115.9672,15],['AKL','Auckland',-37.0082,174.785,21],['HNL','Honolulu',21.3187,-157.9225,21],['ANC','Anchorage',61.1743,-149.9963,6],
['SVO','Moscow',55.9726,37.4146,40],['LED','St Petersburg',59.8003,30.2625,19],['ALA','Almaty',43.3521,77.0405,9],['TAS','Tashkent',41.2579,69.2812,7],
['RKV','Reykjavík',63.985,-22.6056,8],['SLC','Salt Lake City',40.7899,-111.9791,26],['SAN','San Diego',32.7338,-117.1933,25],['PDX','Portland',45.5898,-122.5951,19],
['AUS','Austin',30.1975,-97.6664,21],['BNA','Nashville',36.1263,-86.6774,22],['MSY','New Orleans',29.9934,-90.258,13],['YUL','Montréal',45.4706,-73.7408,21],
['YYC','Calgary',51.1215,-114.0076,18],['GUA','Guatemala City',14.5833,-90.5275,3],['SJO','San José CR',9.9939,-84.2088,5],['UIO','Quito',-0.1292,-78.3575,5],
];
const AP = AIRPORTS.map(([iata, city, lat, lon, w]) => ({ iata, city, lat, lon, w, v: geo.vec(lat, lon) }));
const apByIata = Object.fromEntries(AP.map((a) => [a.iata, a]));
/* ---------- fictional operators ---------- */
const OPS = ['AXR','NVA','SKL','ORX','VLT','KTO','LMA','TRV','QNX','PLR','HZN','CRS','SOL','MRD','EQX','BLU','CYG','ALD','JTR','FRN'];
const OPNAME = { AXR:'Axiom Air', NVA:'Nova Atlantic', SKL:'Skylark', ORX:'Orix Cargo', VLT:'Volta', KTO:'Kito Air', LMA:'Lumea', TRV:'Traverse', QNX:'Quanta', PLR:'Polaris', HZN:'Horizonte', CRS:'Cirrus Lines', SOL:'Solstice', MRD:'Meridian', EQX:'Equinox', BLU:'Bluewing', CYG:'Cygnet', ALD:'Aldea', JTR:'Jetrail', FRN:'Fernway' };
const TYPES_SHORT = ['A320','A21N','B738','B38M','E190','A20N','AT76','CRJ9'];
const TYPES_LONG = ['B77W','A359','B789','A388','B78X','A35K','B744F','A333'];
/* ---------- flights (GPU-animated great circles) ----------
Each flight cycles: fly a→b for dur, turn around, fly b→a… so any scrub time is consistent. */
const FLIGHTS = [];
(function genFlights() {
const N = 3600;
const wsum = AP.reduce((s, a) => s + a.w, 0);
const pickAp = () => { let r = R() * wsum; for (const a of AP) { r -= a.w; if (r <= 0) return a; } return AP[0]; };
let guard = 0;
while (FLIGHTS.length < N && guard++ < N * 8) {
const a = pickAp(); let b = pickAp();
if (a === b) continue;
const d = geo.dist(a.lat, a.lon, b.lat, b.lon) * EARTH_KM;
if (d < 250 || d > 15500) continue;
// short-haul bias
if (d > 6000 && R() < 0.55) continue;
const long = d > 4500;
const speed = long ? 905 : 780; // km/h
const dur = (d / speed + 0.45) * 3600; // seconds
const turn = (long ? R.range(1.5, 3.5) : R.range(0.6, 2.2)) * 3600;
const period = 2 * (dur + turn);
const op = R.pick(OPS);
FLIGHTS.push({
kind: 0, a, b, dKm: d, dur, turn, period, t0: R() * period, alt: long ? R.range(10.6, 12.5) : R.range(8.5, 11.6),
cs: op + (R.int(8900) + 100), op, type: long ? R.pick(TYPES_LONG) : R.pick(TYPES_SHORT), speed,
});
}
// private / GA — short hops near busy regions
for (let i = 0; i < 380; i++) {
const a = R.pick(AP); const brg = R() * TAU, km = R.range(80, 520);
const [lat, lon] = geo.dest(a.lat, a.lon, brg, km);
const b = { iata: 'GA', city: '', lat, lon, w: 1, v: geo.vec(lat, lon) };
const dur = (km / 330 + 0.2) * 3600, turn = R.range(0.5, 4) * 3600;
FLIGHTS.push({ kind: 3, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(1.5, 4.5), cs: 'N' + (R.int(899) + 100) + String.fromCharCode(65 + R.int(26), 65 + R.int(26)), op: 'GA', type: R.pick(['C172','PC12','SR22','C25A','BE20']), speed: 330 });
}
// military transits (fictional callsigns)
const MIL = [[55.0,10.0],[50.0,20.0],[44.0,30.0],[37.0,24.0],[36.5,-76.0],[32.0,-110.0],[26.0,51.0],[35.0,128.0],[21.0,-158.0],[13.5,144.8],[51.0,-1.5],[64.0,25.0],[57.0,22.0],[54.5,18.0]];
const MCS = ['VIPER','TITAN','HAWK','RAVEN','SABRE','ATLAS','GHOST','DUKE','NOMAD','LANCE'];
for (let i = 0; i < 160; i++) {
const c = R.pick(MIL); const [la1, lo1] = geo.dest(c[0], c[1], R() * TAU, R.range(50, 600)); const [la2, lo2] = geo.dest(c[0], c[1], R() * TAU, R.range(300, 1600));
const a = { iata: 'MIL', lat: la1, lon: lo1, v: geo.vec(la1, lo1) }, b = { iata: 'MIL', lat: la2, lon: lo2, v: geo.vec(la2, lo2) };
const km = geo.dist(la1, lo1, la2, lo2) * EARTH_KM; const dur = (km / 760 + 0.2) * 3600, turn = R.range(0.3, 3) * 3600;
FLIGHTS.push({ kind: 1, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(6, 11), cs: R.pick(MCS) + ' ' + (R.int(89) + 10), op: 'MIL', type: R.pick(['KC-135','C-17','P-8','E-3','RC-135','A400','F-35','MQ-9']), speed: 760 });
}
})();
// position of a flight at sim seconds t → {v:[x,y,z] (unit, alt applied), lat, lon, altKm, hdg, prog, onLeg}
function flightAt(f, t) {
let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period;
const leg = ph < f.period / 2 ? 0 : 1; const lp = ph - leg * (f.period / 2);
if (lp > f.dur) return null;
const p = lp / f.dur; const A = leg ? f.b : f.a, B = leg ? f.a : f.b;
const s = geo.slerp(A.v, B.v, p);
const climb = Math.min(1, Math.min(p, 1 - p) * f.dKm / 160);
const altKm = f.alt * Math.sqrt(Math.max(0, climb));
const [lat, lon] = geo.ll(s);
const s2 = geo.slerp(A.v, B.v, Math.min(1, p + 0.001)); const [lat2, lon2] = geo.ll(s2);
return { v: s, lat, lon, altKm, hdg: geo.bearing(lat, lon, lat2, lon2), prog: p, from: A, to: B };
}
/* ---------- shipping lanes (lat,lon chains on water) ---------- */
const LANES = [
// Asia–Europe via Suez
[[31.0,122.6],[28.0,122.6],[24.6,119.9],[22.0,116.0],[18.0,113.5],[12.0,110.4],[7.0,106.0],[2.6,104.9],[1.25,104.0],[1.9,102.4],[3.4,100.6],[5.9,97.2],[6.1,94.0],[6.0,88.0],[5.7,81.0],[7.0,76.5],[11.0,64.0],[12.2,52.0],[12.0,46.5],[12.6,43.4],[14.5,42.4],[18.0,40.3],[22.0,37.9],[25.5,35.3],[27.8,33.7],[29.6,32.6],[30.6,32.33],[31.4,32.33],[32.6,30.0],[34.0,24.5],[36.1,16.0],[37.3,11.6],[37.6,8.0],[36.6,2.0],[35.95,-5.6],[36.6,-9.6],[40.0,-10.2],[43.6,-9.9],[48.4,-5.8],[49.9,-2.3],[50.9,1.3],[51.6,2.8],[52.0,3.95]],
// Transpacific (Shanghai → LA)
[[31.0,122.6],[30.3,128.5],[33.0,135.5],[34.6,139.9],[36.5,152.0],[38.0,165.0],[38.5,180.0],[37.5,-165.0],[35.5,-145.0],[34.0,-128.0],[33.7,-118.4]],
// Busan → Seattle (great circle north)
[[35.0,129.1],[36.5,135.0],[41.4,141.6],[45.0,155.0],[50.0,175.0],[51.5,-170.0],[51.0,-150.0],[49.0,-132.0],[48.4,-124.8]],
// Transatlantic
[[49.9,-2.3],[49.4,-6.4],[47.5,-20.0],[44.5,-40.0],[41.5,-60.0],[40.45,-73.8]],
// US East coast → Panama → west coast S America
[[40.45,-73.8],[35.2,-74.6],[28.0,-77.5],[24.0,-74.5],[20.2,-73.8],[15.0,-77.0],[9.4,-79.9],[8.9,-79.5],[7.2,-80.2],[1.0,-81.3],[-6.0,-81.5],[-12.0,-77.3]],
// Panama → LA
[[8.9,-79.5],[10.5,-87.5],[15.0,-96.5],[20.0,-106.0],[23.0,-110.6],[29.0,-116.0],[33.7,-118.4]],
// Gulf of Mexico → Florida → NY
[[29.3,-94.7],[26.5,-90.0],[24.3,-84.0],[24.2,-81.0],[26.5,-79.7],[31.0,-79.5],[35.2,-74.6]],
// Persian Gulf → Hormuz → Arabian Sea → Gulf of Aden
[[29.4,48.6],[27.3,50.8],[26.5,54.0],[26.5,56.4],[25.2,57.5],[22.6,60.3],[17.0,57.0],[12.2,52.0]],
// Persian Gulf → Malacca
[[25.2,57.5],[22.6,60.3],[17.0,66.0],[10.0,73.0],[6.6,79.3],[5.7,81.0]],
// Cape route
[[22.6,60.3],[10.0,58.0],[-5.0,52.0],[-12.0,45.5],[-20.0,39.5],[-28.0,34.5],[-34.5,26.0],[-35.3,19.5],[-33.0,15.5],[-24.0,11.0],[-10.0,4.0],[2.0,-3.0],[10.0,-18.5],[20.0,-19.5],[28.5,-15.0],[35.5,-10.0],[36.6,-9.6]],
// Singapore → Australia (Lombok)
[[1.25,104.0],[-2.0,107.4],[-6.0,113.0],[-8.7,115.75],[-14.0,117.0],[-20.3,118.6]],
// SE Asia → Hong Kong → Busan
[[22.0,116.0],[25.0,121.8],[29.0,124.0],[33.0,127.8],[35.0,129.1]],
// North Sea → Baltic
[[52.0,3.95],[53.8,6.8],[56.2,7.6],[57.9,10.7],[56.4,12.0],[55.6,12.75],[55.1,14.8],[56.5,18.6],[58.3,20.6],[59.6,23.5],[59.95,27.5],[59.9,29.8]],
// Med: Port Said → Piraeus → Bosphorus → Novorossiysk
[[31.4,32.33],[33.5,28.5],[36.0,25.3],[37.9,23.55],[39.2,24.6],[40.2,26.2],[40.95,28.9],[41.25,29.1],[42.8,31.5],[44.3,36.5],[44.65,37.8]],
// W Med: Gibraltar → Marseille → Genoa
[[35.95,-5.6],[37.0,-1.0],[39.5,2.6],[42.6,4.6],[43.2,5.3],[43.6,7.5],[44.3,8.9]],
// China coast → Japan
[[31.0,122.6],[33.0,126.0],[34.0,130.8],[33.6,135.0],[34.6,139.9]],
// Australia east coast
[[-33.85,151.4],[-30.0,153.8],[-27.3,153.6],[-24.0,152.8],[-19.0,147.8],[-12.0,143.8],[-10.4,142.1],[-9.8,137.0],[-8.6,125.0],[-8.7,115.75]],
// Brazil → West Africa
[[-23.9,-46.3],[-22.9,-41.0],[-15.0,-37.5],[-5.0,-34.4],[2.0,-25.0],[6.0,-8.0],[6.3,3.4]],
];
const LANE_V = LANES.map((l) => { const pts = l.map(([la, lo]) => geo.vec(la, lo)); const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.acos(clamp(v3.dot(pts[i - 1], pts[i]), -1, 1))); return { pts, cum, len: cum[cum.length - 1], raw: l }; });
const SHIPS = [];
(function genShips() {
const NAMES1 = ['Northern','Aurora','Pacific','Silver','Atlas','Coral','Ocean','Polar','Cape','Golden','Blue','Iron','Jade','Amber','Crimson','Morning','Evening','Sea','Star','Harbor'];
const NAMES2 = ['Lark','Spirit','Voyager','Pioneer','Horizon','Dawn','Tide','Meridian','Wave','Crest','Falcon','Unity','Glory','Venture','Breeze','Pearl','Fortune','Harmony','Trader','Express'];
const kinds = ['container','tanker','bulk','LNG','ro-ro','general'];
const tot = LANE_V.reduce((s, l) => s + l.len, 0);
for (let i = 0; i < 2200; i++) {
let r = R() * tot, li = 0; for (; li < LANE_V.length - 1; li++) { if (r < LANE_V[li].len) break; r -= LANE_V[li].len; }
const kn = kinds[R.int(kinds.length)];
const kts = kn === 'container' ? R.range(14, 21) : R.range(10, 15);
SHIPS.push({ lane: li, off: R(), dir: R() < 0.5 ? 1 : -1, speed: (kts * 1.852) / EARTH_KM / 3600, lat0: R.gauss() * 0.12, name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: kn, mmsi: 200000000 + R.int(500000000), dark: R() < 0.012 });
}
// anchorage queues: Suez south & north, Singapore, Rotterdam, LA/LB
const Q = [[29.72,32.56,48,0.09,'suez'],[31.45,32.2,30,0.1,'suez'],[1.22,103.9,90,0.12,'sin'],[52.0,3.6,26,0.12,'rtm'],[33.55,-118.2,22,0.08,'lalb'],[29.1,48.6,18,0.15,''],[22.2,113.9,40,0.12,'']];
for (const [la, lo, n, s, q] of Q) for (let i = 0; i < n; i++) SHIPS.push({ anchor: [la + R.gauss() * s * 0.6, lo + R.gauss() * s], name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: R.pick(kinds), mmsi: 200000000 + R.int(500000000), queue: q, speed: 0 });
})();
function shipAt(s, t) {
if (s.anchor) { const [la, lo] = s.anchor; return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: hash01(s.mmsi) * TAU, sog: 0 }; }
const L = LANE_V[s.lane];
let d = (s.off * L.len + s.dir * s.speed * t) % L.len; if (d < 0) d += L.len;
let i = 1; while (i < L.cum.length - 1 && L.cum[i] < d) i++;
const seg = L.cum[i] - L.cum[i - 1] || 1; const p = (d - L.cum[i - 1]) / seg;
let v = geo.slerp(L.pts[i - 1], L.pts[i], p);
const [la0, lo0] = geo.ll(v);
const brg = geo.bearing(...L.raw[i - 1], ...L.raw[i]) + (s.dir < 0 ? Math.PI : 0);
const [la, lo] = geo.dest(la0, lo0, brg + Math.PI / 2, s.lat0 * 18);
return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: brg, sog: s.speed * EARTH_KM * 3600 / 1.852 };
}
/* ---------- satellites (circular orbits; GPU-propagated) ---------- */
const MU = 398600.4418;
const SATS = [];
function addSat(o) { const a = EARTH_KM + o.alt; o.n = Math.sqrt(MU / (a * a * a)); SATS.push(o); return o; }
(function genSats() {
// Starlink-like shells (53°, 550 km), (70°, 570 km), (97.6°, 560 km)
const shells = [[53, 550, 22, 36], [70, 570, 12, 20], [97.6, 560, 10, 18], [43, 540, 14, 22]];
for (const [inc, alt, planes, per] of shells) for (let p = 0; p < planes; p++) for (let k = 0; k < per; k++)
addSat({ name: 'STARLINK-' + (1000 + SATS.length), inc: inc * D2R, raan: (p / planes) * TAU, u0: (k / per) * TAU + p * 0.13, alt: alt + R.range(-4, 4), kind: 0 });
// other LEO (EO, weather, cubesats)
for (let i = 0; i < 420; i++) addSat({ name: 'LEO-' + (40000 + i), inc: (R() < 0.5 ? R.range(96.5, 99) : R.range(30, 82)) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(380, 1200), kind: 1 });
// GNSS MEO
for (let i = 0; i < 31; i++) addSat({ name: 'GPS ' + (i + 1), inc: 55 * D2R, raan: (Math.floor(i / 5.2) / 6) * TAU, u0: (i % 6) / 6 * TAU + i, alt: 20180, kind: 2 });
for (let i = 0; i < 24; i++) addSat({ name: 'GNSS-E ' + (i + 1), inc: 56 * D2R, raan: (i % 3) / 3 * TAU, u0: i / 8 * TAU, alt: 23222, kind: 2 });
// GEO belt
for (let i = 0; i < 90; i++) addSat({ name: 'GEO-' + (i + 1), inc: R.range(0, 0.08) * D2R, raan: 0, u0: R() * TAU, alt: 35786, kind: 3, geo: true });
// debris
for (let i = 0; i < 600; i++) addSat({ name: 'DEB ' + (20000 + i), inc: R.range(60, 100) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(650, 1500), kind: 4 });
// Starlink train (fresh launch, low and tight)
for (let i = 0; i < 24; i++) addSat({ name: 'STARLINK-T' + (i + 1), inc: 43 * D2R, raan: 1.9, u0: 2.4 + i * 0.006, alt: 312, kind: 5 });
})();
const ISS = addSat({ name: 'ISS (ZARYA)', inc: 51.64 * D2R, raan: 4.1, u0: 0.7, alt: 418, kind: 6 });
const CSS = addSat({ name: 'CSS (TIANHE)', inc: 41.47 * D2R, raan: 2.2, u0: 3.1, alt: 385, kind: 6 });
function satAt(s, t, gmst) { // ECEF unit vector with altitude
const u = s.u0 + s.n * t, r = 1 + s.alt / EARTH_KM;
const cu = Math.cos(u), su = Math.sin(u), cO = Math.cos(s.raan), sO = Math.sin(s.raan), ci = Math.cos(s.inc), si = Math.sin(s.inc);
let x = cu * cO - su * ci * sO, y = cu * sO + su * ci * cO, z = su * si;
if (s.geo) { const g = gmst; const c = Math.cos(s.u0), sn = Math.sin(s.u0); return [r * c, r * sn, z * r]; }
const cg = Math.cos(-gmst), sg = Math.sin(-gmst);
return [r * (x * cg - y * sg), r * (x * sg + y * cg), r * z];
}
/* ---------- quakes (illustrative, along plate boundaries) ---------- */
const QUAKE_ZONES = [[35.5,141.9],[38.3,142.4],[-6,130],[-20,-70],[-33,-72],[13,-90],[61,-148],[52,-170],[-41,174],[36,71],[28,84],[38,26],[-17,168],[-5,152],[18,-68],[40,30],[37,-121],[-1,-77],[14,121],[24,123]];
const QUAKES = [];
for (let i = 0; i < 46; i++) {
const z = R.pick(QUAKE_ZONES);
QUAKES.push({ lat: z[0] + R.gauss() * 1.6, lon: z[1] + R.gauss() * 1.6, mag: +(2.6 + Math.pow(R(), 2.6) * 3.2).toFixed(1), t: R.range(-72, 0) * 3600, depth: Math.round(R.range(8, 120)) });
}
const BIGQ = { lat: 35.62, lon: 141.95, mag: 6.4, t: -5.4 * 3600, depth: 32, place: 'off the coast of Honshu' };
QUAKES.push(BIGQ);
for (let i = 0; i < 9; i++) QUAKES.push({ lat: BIGQ.lat + R.gauss() * 0.35, lon: BIGQ.lon + R.gauss() * 0.4, mag: +(3.4 + R() * 1.6).toFixed(1), t: BIGQ.t + R.range(0.2, 5) * 3600, depth: Math.round(R.range(10, 50)) });
/* ---------- fires (FIRMS-like hotspots; start times so the ribbon shows spread) ---------- */
const FIRE_CLUSTERS = [
[40.2,-8.1,'Central Portugal',90,1.0],[42.4,-7.4,'Galicia',60,0.9],[38.1,23.4,'Attica',50,0.7],[39.8,21.0,'Epirus',30,0.5],[36.8,30.6,'Antalya',40,0.6],
[34.4,-118.5,'Los Angeles County',45,0.8],[39.7,-121.6,'Butte County',40,0.6],[-14.0,-60.0,'Rondônia',180,1.6],[-9.5,24.0,'Katanga savanna',260,2.6],
[8.5,19.0,'Central African savanna',220,2.6],[-17.0,131.0,'Northern Territory',140,2.0],[61.5,128.0,'Yakutia',60,1.3],[-2.5,113.5,'Kalimantan',70,1.0],
[21.0,98.0,'Shan State',60,1.2],[-22.0,-55.0,'Mato Grosso do Sul',70,1.2],
];
const FIRES = [];
for (const [la, lo, name, n, s] of FIRE_CLUSTERS) {
const tStart = R.range(-70, -6) * 3600;
for (let i = 0; i < n; i++) {
const rad = Math.sqrt(R()) * s, ang = R() * TAU;
// fronts move with prevailing wind: later ignitions sit downwind
const tt = tStart + (rad / s) * R.range(20, 110) * 3600;
FIRES.push({ lat: la + Math.sin(ang) * rad * 0.8, lon: lo + Math.cos(ang) * rad, t: tt, frp: R.range(5, 120), cluster: name });
}
}
/* ---------- tropical cyclone (illustrative name) ---------- */
const CYCLONE = {
name: 'Typhoon Halcyon', designation: '26W (illustrative)',
// [hoursFromNow, lat, lon, windKmh, pressure hPa]
track: [[-72,11.2,141.8,95,992],[-60,11.9,139.6,110,985],[-48,12.6,137.4,130,975],[-36,13.4,135.3,150,965],[-24,14.3,133.1,170,955],[-12,15.2,130.9,180,948],[0,16.2,128.8,185,944],
[12,17.3,126.9,195,938],[24,18.6,125.1,200,935],[36,20.1,123.6,190,940],[48,21.8,122.5,175,950],[72,25.0,121.9,140,968],[96,28.6,124.4,110,980],[120,32.4,129.0,85,990]],
at(hours) {
const tr = this.track; if (hours <= tr[0][0]) return { lat: tr[0][1], lon: tr[0][2], wind: tr[0][3], p: tr[0][4] };
for (let i = 1; i < tr.length; i++) if (hours <= tr[i][0]) {
const a = tr[i - 1], b = tr[i], f = (hours - a[0]) / (b[0] - a[0]);
return { lat: lerp(a[1], b[1], f), lon: lerp(a[2], b[2], f), wind: lerp(a[3], b[3], f), p: lerp(a[4], b[4], f) };
}
const z = tr[tr.length - 1]; return { lat: z[1], lon: z[2], wind: z[3], p: z[4] };
},
cone(hours) { return hours <= 0 ? 0 : 35 + hours * 3.1; }, // km radius of uncertainty
};
/* ---------- cities (search + scatter) ---------- */
const CITIES = [
['New York',40.7128,-74.006],['London',51.5072,-0.1276],['Tokyo',35.6762,139.6503],['Paris',48.8566,2.3522],['Shanghai',31.2304,121.4737],['Mumbai',19.076,72.8777],
['São Paulo',-23.5505,-46.6333],['Mexico City',19.4326,-99.1332],['Cairo',30.0444,31.2357],['Lagos',6.5244,3.3792],['Istanbul',41.0082,28.9784],['Moscow',55.7558,37.6173],
['Seoul',37.5665,126.978],['Jakarta',-6.2088,106.8456],['Delhi',28.6139,77.209],['Los Angeles',34.0522,-118.2437],['Chicago',41.8781,-87.6298],['Singapore',1.3521,103.8198],
['Sydney',-33.8688,151.2093],['Dubai',25.2048,55.2708],['Hong Kong',22.3193,114.1694],['Berlin',52.52,13.405],['Madrid',40.4168,-3.7038],['Rome',41.9028,12.4964],
['Bangkok',13.7563,100.5018],['Toronto',43.6532,-79.3832],['San Francisco',37.7749,-122.4194],['Buenos Aires',-34.6037,-58.3816],['Johannesburg',-26.2041,28.0473],['Nairobi',-1.2921,36.8219],
['Kathmandu',27.7172,85.324],['Lisbon',38.7223,-9.1393],['Athens',37.9838,23.7275],['Reykjavík',64.1466,-21.9426],['Tromsø',69.6492,18.9553],['Honolulu',21.3069,-157.8583],
['Manila',14.5995,120.9842],['Taipei',25.033,121.5654],['Rotterdam',51.9244,4.4777],['Suez',29.9668,32.5498],['Cape Canaveral',28.3922,-80.6077],['Dothan',31.2232,-85.3905],
];
const CITY_V = CITIES.map(([n, la, lo]) => ({ name: n, lat: la, lon: lo }));
/* ---------- people (fictional handles) ---------- */
const PEOPLE = {
you: { h: 'you', name: 'You', hue: 205 },
kora: { h: 'kora_maps', name: 'Kora M.', hue: 160 },
tamang: { h: 'tamang_lens', name: 'Pemba T.', hue: 30 },
rotor: { h: 'rotorwash_ret', name: 'J. Alder', hue: 95 },
hydro: { h: 'hydro_nerd', name: 'Sita R.', hue: 200 },
ais: { h: 'ais_owl', name: 'Marek', hue: 270 },
sky: { h: 'skywatch_ana', name: 'Ana P.', hue: 330 },
geo: { h: 'geodesy_dan', name: 'Dan K.', hue: 50 },
crowd: { h: 'row_m_seat14', name: 'Lena', hue: 300 },
news: { h: 'chopper_cam9', name: 'Desk 9', hue: 10 },
ai: { h: 'claude', name: 'AI (proposal)', hue: 260, ai: true },
};
Object.assign(GE, { AP, apByIata, FLIGHTS, flightAt, OPNAME, LANES, LANE_V, SHIPS, shipAt, SATS, ISS, CSS, satAt, QUAKES, BIGQ, FIRES, FIRE_CLUSTERS, CYCLONE, CITY_V, PEOPLE });
+870
View File
@@ -0,0 +1,870 @@
/* Hand-written WebGL2 renderer: ray-traced planet + single-scatter atmosphere, sprites, trails,
lines, meshes (city/terrain/water) and a mobile post chain (half-res dual-Kawase bloom, FXAA-lite,
ACES, grade, vignette, grain) with lens modes (cinematic / clear / night / thermal / NVG). */
const GL = (GE.GL = {});
const GLSL_COMMON = `
const float PI = 3.14159265;
float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
vec2 hash22(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*vec3(.1031,.1030,.0973)); p3 += dot(p3,p3.yzx+33.33); return fract((p3.xx+p3.yz)*p3.zy); }
float hash13(vec3 p3){ p3 = fract(p3*.1031); p3 += dot(p3,p3.zyx+31.32); return fract((p3.x+p3.y)*p3.z); }
float vnoise(vec2 p){ vec2 i=floor(p), f=fract(p); vec2 u=f*f*(3.-2.*f);
return mix(mix(hash12(i),hash12(i+vec2(1,0)),u.x), mix(hash12(i+vec2(0,1)),hash12(i+vec2(1,1)),u.x), u.y); }
float fbm(vec2 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise(p); p=p*2.03+vec2(1.7,9.2); a*=.5;} return s; }
float vnoise3(vec3 p){ vec3 i=floor(p), f=fract(p); vec3 u=f*f*(3.-2.*f);
float a=hash13(i), b=hash13(i+vec3(1,0,0)), c=hash13(i+vec3(0,1,0)), d=hash13(i+vec3(1,1,0));
float e=hash13(i+vec3(0,0,1)), g=hash13(i+vec3(1,0,1)), h=hash13(i+vec3(0,1,1)), k=hash13(i+vec3(1,1,1));
return mix(mix(mix(a,b,u.x),mix(c,d,u.x),u.y), mix(mix(e,g,u.x),mix(h,k,u.x),u.y), u.z); }
float fbm3(vec3 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise3(p); p=p*2.02+vec3(3.1,1.7,5.3); a*=.5;} return s; }
`;
const GLSL_ATMO = `
const float HR = 0.0021, HM = 0.0006, RA = 1.0165;
const vec3 BR = vec3(22.1, 51.5, 126.3);
const vec3 BM = vec3(16.0);
uniform vec3 uSun;
uniform float uSunI;
vec2 raySphere(vec3 ro, vec3 rd, float r){ float b=dot(ro,rd); float c=dot(ro,ro)-r*r; float d=b*b-c; if(d<0.) return vec2(1e9,-1e9); d=sqrt(d); return vec2(-b-d,-b+d); }
// optical depth (length units) of exp(-(r-1)/H) from radius r along zenith-cosine mu to infinity
float odH(float r, float mu, float H){
float c = sqrt(PI*0.5*r/H); float h=(r-1.)/H;
if (mu >= 0.) return H*exp(-h)*c/(c*mu+1.);
float r0 = r*sqrt(max(0.,1.-mu*mu));
if (r0 < 1.0) return 1e4;
float h0 = (r0-1.)/H; float c0 = sqrt(PI*0.5*r0/H);
return H*(2.*exp(-h0)*c0 - exp(-h)*c/(-c*mu+1.));
}
vec3 sunTrans(vec3 x){ float r=length(x); float mu=dot(x,uSun)/r; return exp(-(BR*odH(r,mu,HR)+BM*1.11*odH(r,mu,HM))); }
vec3 atmo(vec3 ro, vec3 rd, float tEnd, float inside, int steps, out vec3 T){
vec2 ta = raySphere(ro, rd, RA);
float t0 = max(ta.x, 0.), t1 = min(ta.y, tEnd);
T = vec3(1.);
if (t1 <= t0) return vec3(0.);
float mu = dot(rd, uSun);
float pR = 3./(16.*PI)*(1.+mu*mu);
const float g = 0.76;
float pM = 3./(8.*PI)*((1.-g*g)*(1.+mu*mu))/((2.+g*g)*pow(max(1.+g*g-2.*g*mu,1e-4),1.5));
vec3 sR=vec3(0.), sM=vec3(0.); float oR=0., oM=0.; float L=t1-t0;
float N = float(steps);
for (int i=0;i<24;i++){
if (i>=steps) break;
float fa = float(i)/N, fb = float(i+1)/N;
float ta0 = mix(fa, fa*fa, inside), tb0 = mix(fb, fb*fb, inside);
float ds = (tb0-ta0)*L; float t = t0 + (ta0+tb0)*0.5*L;
vec3 x = ro + rd*t; float r = length(x); float h=r-1.;
float dR = exp(-h/HR), dM = exp(-h/HM);
oR += dR*ds; oM += dM*ds;
float muS = dot(x,uSun)/r;
vec3 tau = BR*(oR+odH(r,muS,HR)) + BM*1.11*(oM+odH(r,muS,HM));
vec3 att = exp(-tau);
sR += dR*att*ds; sM += dM*att*ds;
}
T = exp(-(BR*oR + BM*1.11*oM));
return uSunI*(sR*BR*pR + sM*BM*pM);
}
`;
const SRC = {};
SRC.fsq_vs = `#version 300 es
out vec2 vUv; out vec2 vNdc;
void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2); vUv=p; vNdc=p*2.-1.; gl_Position=vec4(p*2.-1.,0.,1.); }`;
SRC.globe_fs = `#version 300 es
precision highp float;
in vec2 vNdc; out vec4 frag;
${GLSL_COMMON}
${GLSL_ATMO}
uniform vec3 uCamR, uCamU, uCamB; uniform vec4 uTanS; uniform vec3 uEye; uniform float uEyeC; uniform float uT; uniform float uGmst;
uniform sampler2D uDay, uNight, uClouds, uWater, uTopo;
uniform vec4 uLens; // x mode, y night force, z cloud amount, w graticule
uniform vec4 uCyc; // xyz center, w radius (rad)
uniform vec4 uCycP; // x intensity, y spin, z eye radius
uniform float uAurora;
uniform mat3 uEnu; uniform vec3 uEyeLocal; uniform vec4 uCity; // x detail, y block m, z rot, w urban boost
uniform float uRange; uniform int uSteps; uniform float uInside;
uniform vec4 uFocus; // xyz focus dir, w strength (dim surroundings)
uniform vec4 uLocalWater; // xy normal, z offset (m), w enabled
uniform float uTraffic;
vec2 uvOf(vec3 n){ return vec2(atan(n.y,n.x)/(2.*PI)+0.5, 0.5-asin(clamp(n.z,-1.,1.))/PI); }
vec4 tex2(sampler2D s, vec2 uv){
vec2 u2 = vec2(fract(uv.x+0.5)-0.5, uv.y);
vec2 dx = dFdx(uv), dy = dFdy(uv), dx2 = dFdx(u2), dy2 = dFdy(u2);
if (abs(dx2.x)+abs(dy2.x) < abs(dx.x)+abs(dy.x)) { dx = dx2; dy = dy2; }
return textureGrad(s, uv, dx, dy);
}
vec3 stars(vec3 d){
float c = cos(uGmst), s = sin(uGmst);
d = vec3(c*d.x - s*d.y, s*d.x + c*d.y, d.z);
vec3 col = vec3(0.);
for (int L=0; L<3; L++){
float sc = 60. + float(L)*70.;
vec3 a = abs(d); vec2 uv; float face;
if (a.x>a.y && a.x>a.z){ uv=d.yz/a.x; face=sign(d.x); }
else if (a.y>a.z){ uv=d.xz/a.y; face=2.+sign(d.y); }
else { uv=d.xy/a.z; face=4.+sign(d.z); }
vec2 g = uv*sc; vec2 id = floor(g); vec2 f = fract(g)-0.5;
vec2 rnd = hash22(id+face*17.+float(L)*31.);
float b = pow(rnd.x, 70.) * (L==0 ? 1.4 : 0.8);
vec2 off = (hash22(id*1.7+face)-0.5)*0.7;
float dd = length(f-off);
float w = fwidth(g.x)*0.9+0.02;
col += b * smoothstep(w*1.25, 0., dd) * mix(vec3(1.,.84,.7), vec3(.75,.85,1.), rnd.y) * 4.;
}
// milky way band (galactic north pole RA 192.86°, Dec 27.13°)
vec3 gnp = vec3(-0.8676, -0.1981, 0.4560);
float gb = dot(d, gnp);
float band = exp(-gb*gb*14.);
float n = fbm3(d*6.)*0.7 + fbm3(d*17.)*0.3;
col += band * smoothstep(0.35, 0.85, n) * vec3(0.10, 0.11, 0.15) * 0.9;
col += band * vec3(0.012,0.013,0.02);
return col;
}
float cycloneDensity(vec3 n){
if (uCycP.x <= 0.) return 0.;
float cd = dot(n, uCyc.xyz); if (cd < 0.985) return 0.;
float d = acos(clamp(cd,-1.,1.));
vec3 e1 = normalize(cross(vec3(0,0,1), uCyc.xyz)); vec3 e2 = cross(uCyc.xyz, e1);
vec3 v = n - uCyc.xyz*cd; float th = atan(dot(v,e2), dot(v,e1));
float x = d/uCyc.w;
float sw = 2.*th + 5.2*log(max(x,0.03)) - uCycP.y;
float arms = pow(0.5 + 0.5*sin(sw), 2.4);
float tex = fbm(vec2(cos(th), sin(th))*x*9. + vec2(3.*log(x+0.05) - uCycP.y*0.2, uCycP.y*0.05));
float dens = smoothstep(1.25, 0.28, x) * (0.12 + 0.88*arms) * (0.35 + 0.9*tex);
float cdo = smoothstep(0.26, 0.08, x) * (0.75 + 0.25*tex);
dens = max(dens, cdo);
float eye = smoothstep(uCycP.z*0.8, uCycP.z*1.6, d) * (1. + 0.25*smoothstep(uCycP.z*2.6, uCycP.z*1.6, d));
return clamp(dens*1.15, 0., 1.) * eye * uCycP.x;
}
vec3 aurora(vec3 n, float nightF){
if (uAurora <= 0.) return vec3(0.);
vec3 mp = vec3(0.0484, -0.1559, 0.9866);
float s = dot(n, mp); float hemi = sign(s);
float ml = asin(abs(s)) * 57.2958;
float band = 66.5 - uAurora*5.5;
float lonm = atan(dot(n, normalize(cross(mp, vec3(0,0,1)))), dot(n, normalize(cross(mp, cross(mp, vec3(0,0,1))))));
float curt = 0.45 + 0.55*fbm(vec2(lonm*9. + uT*0.0006, uT*0.00025));
float g = exp(-pow((ml-band)/(2.2+uAurora*0.6), 2.)) * curt;
float r = exp(-pow((ml-band-3.5)/3.5, 2.)) * 0.25;
return (vec3(0.12, 1.0, 0.5)*g + vec3(0.75, 0.15, 0.45)*r*0.45) * nightF * uAurora * 1.15;
}
vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3 emis){
emis = vec3(0.);
float rot = uCity.z; mat2 m = mat2(cos(rot), -sin(rot), sin(rot), cos(rot));
vec2 p = m*q; float B = uCity.y;
vec2 g = p/B; vec2 id = floor(g); vec2 f = fract(g);
float big = step(0.8, hash12(floor(id/5.)+3.));
float rw = 0.085 + 0.05*step(4.5, mod(id.x,5.)) ;
vec2 e = min(f, 1.-f);
float road = 1. - smoothstep(rw*0.75, rw, min(e.x, e.y));
float aa = clamp(fw/B*4., 0., 1.);
road = mix(road, 0.18, aa);
float h = hash12(id);
vec3 roof = mix(vec3(0.20,0.20,0.21), vec3(0.42,0.38,0.33), h);
roof = mix(roof, vec3(0.30,0.36,0.26), step(0.86, h)); // parks
float lot = vnoise(p*0.11)*0.12;
vec3 urb = mix(roof*(0.9+lot), vec3(0.075,0.078,0.085), road);
// rural: patchwork fields & forest
vec2 fid = floor(p/(B*2.6)); float fh = hash12(fid+7.);
vec3 field = base * mix(vec3(0.9,1.05,0.8), vec3(1.25,1.1,0.75), fh) * (0.85+0.3*vnoise(p*0.02));
float forest = smoothstep(0.45,0.62, fbm(p*0.0016+3.));
field = mix(field, base*vec3(0.55,0.75,0.5), forest);
vec3 col = mix(field, urb, urban);
// street lights at night along roads
float lamps = road * urban * (0.5+0.5*hash12(floor(p/9.)));
emis = vec3(1.0,0.58,0.24)*lamps*night*1.1*(1.-aa*0.55);
if (uTraffic > 0.5) {
vec2 rp = p/B; float along = abs(f.x-0.5) < abs(f.y-0.5) ? rp.y : rp.x;
float cars = smoothstep(0.75, 1., sin(along*38. - uT*2.5 + h*20.)) * road * urban * (1.-aa);
emis += mix(vec3(1.,0.15,0.1), vec3(1.,0.95,0.8), step(0.5, fract(along*3.)))*cars*(0.25 + night*1.4);
}
float lit = step(0.72, hash12(id*3.1)) * (1.-road) * urban * night;
emis += vec3(0.9,0.8,0.6)*lit*0.035;
return col;
}
void main(){
vec3 rd = normalize(uCamR*(vNdc.x - uTanS.z)*uTanS.x + uCamU*(vNdc.y - uTanS.w)*uTanS.y - uCamB);
vec3 ro = uEye;
float b = dot(ro, rd);
float disc = b*b - uEyeC;
int mode = int(uLens.x+0.5);
float clear = mode==1 ? 1. : 0.;
vec3 col; float heat = 0.;
vec3 T; vec3 ins;
float nightForce = uLens.y;
if (disc > 0. && b < 0.) {
float t = uEyeC / (-b + sqrt(disc));
vec3 p = ro + rd*t; vec3 n = normalize(p);
vec2 uv = uvOf(n);
vec3 day = pow(tex2(uDay, uv).rgb, vec3(2.2));
vec3 nightTex = tex2(uNight, uv).rgb;
float water = tex2(uWater, uv).r;
float muS = dot(n, uSun);
// relief from topography
vec2 tx = vec2(1./2048., 1./1024.);
float h0 = texture(uTopo, uv).r;
float hx = texture(uTopo, uv+vec2(tx.x,0.)).r - texture(uTopo, uv-vec2(tx.x,0.)).r;
float hy = texture(uTopo, uv-vec2(0.,tx.y)).r - texture(uTopo, uv+vec2(0.,tx.y)).r;
vec3 east = normalize(vec3(-n.y, n.x, 0.)+1e-6); vec3 north = cross(n, east);
float bumpK = 2.2 * (1.-water);
vec3 nb = normalize(n - (east*hx + north*hy)*bumpK/ max(cos(asin(n.z)),0.2) * 0.6);
float lights = max(0., (nightTex.g*1.1 - nightTex.b*0.55));
lights = pow(lights, 1.7) * 2.6;
float urban = smoothstep(0.03, 0.25, lights) + uCity.w;
// low-altitude synthetic ground detail
vec3 emisD = vec3(0.);
float nightF = 1. - smoothstep(-0.16, 0.06, muS);
nightF = max(nightF, nightForce);
if (uCity.x > 0.001) {
vec3 rdl = transpose(uEnu) * rd; vec2 q = (uEyeLocal + rdl * (t*6371000.)).xy;
if (uLocalWater.w > 0.) { float lw = smoothstep(uLocalWater.z-25., uLocalWater.z+25., dot(q, uLocalWater.xy)); water = max(water, lw*uCity.x); day = mix(day, vec3(0.012,0.03,0.045), lw*uCity.x); }
float fw = length(fwidth(q));
vec3 dcol = cityDetail(day, q, clamp(urban,0.,1.)*(1.-water), nightF, fw, emisD);
float fade = uCity.x * (1.-smoothstep(150., 900., fw)) * (1.-water*0.9);
day = mix(day, dcol, fade); emisD *= fade;
}
// clouds (+ cyclone)
vec2 cuv = uv + vec2(uT*1.2e-7, 0.);
vec3 wp = n*5. + vec3(uT*2e-6);
cuv += (vec2(fbm3(wp), fbm3(wp+7.3))-0.5)*0.012;
float cl = tex2(uClouds, cuv).r;
float dK = smoothstep(1.2, 0.08, uRange);
float base0 = smoothstep(0.1, 0.9, cl);
if (dK > 0.) {
vec3 q3 = n*420. + vec3(uT*1.5e-5, 0., uT*1e-5);
float n1 = fbm3(q3), n2 = fbm3(q3*4.1 + 7.);
float cells = smoothstep(0.62 - cl*0.55, 0.92 - cl*0.35, n1*0.62 + n2*0.38 + cl*0.18);
cl = mix(base0, cells * smoothstep(0.03, 0.3, cl), dK);
} else cl = base0;
cl *= 0.94;
cl = max(cl, cycloneDensity(n));
float cloudFade = smoothstep(0.0012, 0.006, uRange);
cl *= uLens.z * cloudFade * (1.-clear);
vec3 st = sunTrans(p*1.0005);
float shade = mix(1.0, 1.0-0.55*tex2(uClouds, cuv - vec2(uSun.y, -uSun.z)*0.0015).r*uLens.z*cloudFade, step(0.,muS));
float diff = max(dot(nb, uSun), 0.);
vec3 skyAmb = vec3(0.022,0.036,0.07)*smoothstep(-0.25,0.5,muS);
vec3 irr = uSunI*(st*diff*shade + skyAmb);
if (clear > 0.5) irr = vec3(uSunI*0.32);
irr *= (1.-nightForce*0.97);
irr += vec3(0.05,0.07,0.13)*nightF*(1.-clear);
vec3 alb = day;
vec3 surf = alb * irr / PI;
// ocean specular
if (water > 0.01) {
vec3 v = -rd;
vec3 nw = n;
if (uRange < 0.05) { vec2 wq = uv*vec2(4000.,2000.)+uT*0.02; nw = normalize(n + (east*(vnoise(wq)-.5) + north*(vnoise(wq.yx+3.)-.5))*0.04); }
vec3 hv = normalize(v + uSun);
float nh = max(dot(nw, hv), 0.);
float rough = 0.18;
float a2 = rough*rough; float dd = nh*nh*(a2-1.)+1.; float D = a2/(PI*dd*dd);
float F = 0.02 + 0.98*pow(1.-max(dot(v,hv),0.), 5.);
vec3 spec = uSunI * st * D * F * max(dot(nw,uSun),0.) * 0.25;
float fres = 0.02 + 0.98*pow(1.-max(dot(nw,v),0.), 5.);
vec3 skyR = vec3(0.12,0.22,0.45)*uSunI*0.05*smoothstep(-0.2,0.4,muS);
surf += (spec*(1.-clear) + skyR*fres) * water * (1.-cl);
}
// night lights + moonlit black-marble land
vec3 lightCol = mix(vec3(1.0,0.66,0.32), vec3(1.0,0.86,0.64), smoothstep(0.4,1.4,lights));
vec3 emis = lightCol * lights * 3.2 * nightF * (1. - uCity.x*0.94);
emis += pow(nightTex, vec3(2.2)) * vec3(0.5,0.6,1.0) * 0.12 * nightF;
emis += emisD;
emis *= (1.-cl*0.82);
// clouds lit
vec3 cirr = uSunI*(st*(0.25+0.75*max(dot(n,uSun)*0.9+0.1,0.)) + skyAmb*2.);
if (clear > 0.5) cirr = vec3(uSunI*0.3);
vec3 ccol = vec3(0.78) * cirr/PI + vec3(0.008,0.01,0.018)*nightF + lightCol*lights*0.25*nightF;
surf = mix(surf, ccol, cl);
surf += emis;
surf += aurora(n, nightF);
// graticule (clear lens)
if (uLens.w > 0.) {
vec2 ll = vec2(atan(n.y,n.x), asin(n.z))*57.2958/15.;
vec2 gg = abs(fract(ll+0.5)-0.5)/fwidth(ll);
float lineG = 1.-min(min(gg.x,gg.y),1.);
surf += vec3(0.35,0.5,0.7)*lineG*0.35*uLens.w;
}
// focus: gently dim surroundings of a situation
if (uFocus.w > 0.) { float fd = acos(clamp(dot(n, uFocus.xyz),-1.,1.)); surf *= mix(1., 0.55, uFocus.w*smoothstep(0.08, 0.5, fd)); }
ins = atmo(ro, rd, t, uInside, uSteps, T);
if (clear > 0.5) { ins *= 0.35; T = mix(T, vec3(1.), 0.6); }
col = surf*T + ins;
// heat: land warm by day, cities warm, ocean cool, clouds cold
heat = mix(0.32 + 0.38*diff*(1.-nightForce), 0.2 - 0.12*abs(n.z), water) + 0.25*clamp(lights,0.,1.);
heat = mix(heat, 0.06, cl);
} else {
// sky / space
ins = atmo(ro, rd, 1e9, uInside, uSteps, T);
vec3 sp = stars(rd) * (1.-clear*0.6);
float sd = dot(rd, uSun);
vec3 sun = vec3(1.,0.96,0.9) * (smoothstep(0.99996, 0.99999, sd)*400. + pow(max(sd,0.),900.)*3. + pow(max(sd,0.),40.)*0.08) * (1.-clear);
col = (sp + sun)*T + ins;
// aurora curtains at the limb
float tc = -b; vec3 cp = ro + rd*max(tc,0.); float hr = length(cp);
if (uAurora > 0. && hr > 1.004 && hr < 1.05) {
vec3 cn = normalize(cp); float nf = 1.-smoothstep(-0.2,0.0,dot(cn,uSun));
col += aurora(cn, nf) * smoothstep(1.05,1.012,hr) * smoothstep(1.004,1.012,hr) * 0.3;
}
heat = 0.;
}
frag = vec4(col, heat);
}`;
/* ---------- sprites ---------- */
const SPRITE_VS_HEAD = `#version 300 es
precision highp float;
uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR;
uniform float uPx; uniform float uT; uniform float uAltX; uniform float uSizeK; uniform float uGmst; uniform vec2 uView;
uniform float uMinSize; uniform float uAlphaK; uniform vec3 uSun;
out vec4 vCol; out float vGlyph; out float vAng; out float vSize; out float vSeed; out float vHeat;
float occluded(vec3 p){
vec3 E = uEyeH + uEyeL; vec3 d = p - E; float L = length(d); vec3 u = d/L;
float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c;
if (disc <= 0.) return 0.;
float tn = -b - sqrt(disc);
return (tn > 0. && tn < L) ? 1. : 0.;
}
vec4 project(vec3 p){ vec3 rel = (p - uEyeH) - uEyeL; vec4 c = uVP*vec4(rel,1.); if (c.z > c.w) c.z = c.w*0.99999; return c; }
float screenAngle(vec3 p, vec3 dir, vec4 c0){
vec3 rel = (p - uEyeH) - uEyeL; float s = length(rel)*0.01 + 1e-7;
vec4 c1 = uVP*vec4(rel + dir*s, 1.);
vec2 a = c0.xy/c0.w, bb = c1.xy/c1.w;
return atan((bb.y-a.y)*uView.y, (bb.x-a.x)*uView.x);
}
vec3 headingDir(vec3 p, float hdg){ vec3 up = normalize(p); vec3 e = normalize(vec3(-up.y, up.x, 0.)+1e-7); vec3 nn = cross(up, e); return nn*cos(hdg) + e*sin(hdg); }
`;
SRC.sprite_vs = SPRITE_VS_HEAD + `
in vec3 aPos; in vec4 aCol; in vec4 aPar; // size, glyph, heading, t0 (seconds; visible after)
in vec4 aExt; // x: tEnd, y: grow, z: seed, w: heat
void main(){
vec3 p = aPos;
float vis = step(aPar.w, uT) * step(uT, aExt.x);
vec4 c = project(p);
float occ = occluded(p);
gl_Position = c;
float age = uT - aPar.w;
float grow = 1. + aExt.y * clamp(age/86400., 0., 2.);
float sz = max(aPar.x * uSizeK * grow, uMinSize);
gl_PointSize = vis * (1.-occ) * sz * uPx;
vCol = aCol; vCol.a *= uAlphaK; vGlyph = aPar.y; vSize = sz*uPx; vSeed = aExt.z + age*0.0;
vAng = aPar.z > -9. ? screenAngle(p, headingDir(p, aPar.z), c) : 0.;
vHeat = aExt.w;
if (aPar.y > 1.5 && aPar.y < 2.5) vSeed = age; // quake ring: pass age
}`;
SRC.flight_vs = SPRITE_VS_HEAD + `
in vec3 aA; in vec3 aB; in vec4 aTime; in vec4 aMeta; // t0,dur,period,_ ; altKm, kind, seed, dKm
uniform float uTrail; uniform float uTrailLen; in float aK;
uniform vec4 uKindMask; uniform float uSelId; uniform vec4 uKindSize;
out float vK;
vec3 flightPos(float t, out float vis, out vec3 dir){
float ph = mod(t - aTime.x, aTime.z);
float hf = aTime.z*0.5; float leg = step(hf, ph); float lp = ph - leg*hf;
vis = step(lp, aTime.y) * step(0., lp);
float pr = clamp(lp/aTime.y, 0., 1.);
vec3 A = mix(aA, aB, leg), B = mix(aB, aA, leg);
float om = acos(clamp(dot(A,B), -1., 1.)); float so = max(sin(om), 1e-5);
vec3 s = (sin((1.-pr)*om)*A + sin(pr*om)*B)/so;
vec3 s2 = (sin((1.-pr-0.002)*om)*A + sin((pr+0.002)*om)*B)/so;
dir = normalize(s2 - s);
float climb = clamp(min(pr,1.-pr)*aMeta.w/160., 0., 1.);
float alt = aMeta.x*sqrt(climb);
return s*(1. + alt*uAltX/6371.);
}
void main(){
int kind = int(aMeta.y+0.5);
float km = kind==0?uKindMask.x: kind==1?uKindMask.y: kind==2?uKindMask.z: uKindMask.w;
float vis; vec3 dir;
float t = uT - aK*uTrailLen;
vec3 p = flightPos(t, vis, dir);
vec4 c = project(p);
float occ = occluded(p);
gl_Position = c;
float sel = float(abs(float(gl_InstanceID) - uSelId) < 0.5);
float ks = kind==0?uKindSize.x: kind==1?uKindSize.y: kind==2?uKindSize.z: uKindSize.w;
float sz = max(ks*uSizeK*(1.+sel*0.8), uMinSize);
gl_PointSize = vis*(1.-occ)*km*sz*uPx;
vec3 colK = kind==0 ? vec3(0.42,0.74,1.0) : kind==1 ? vec3(1.0,0.56,0.3) : kind==2 ? vec3(0.55,1.0,0.5) : vec3(0.78,0.85,1.0);
colK = mix(colK, vec3(1.0,0.86,0.45), sel);
vCol = vec4(colK, vis*(1.-occ)*km*uAlphaK*(uTrail>0.5 ? pow(1.-aK,1.6)*(0.55+sel*0.45) : 1.));
vK = aK;
vGlyph = 1.; vSize = sz*uPx; vSeed = aMeta.z; vHeat = 0.55;
vAng = screenAngle(p, dir, c);
}`;
SRC.sat_vs = SPRITE_VS_HEAD + `
in vec4 aEl; in vec4 aSM; // inc, raan, u0, n ; altKm, kind, geo, seed
uniform vec4 uSatMask0; uniform vec4 uSatMask1; uniform float uSelId;
void main(){
float u = aEl.z + aEl.w*uT; float r = 1. + aSM.x/6371.;
float cu=cos(u), su=sin(u), cO=cos(aEl.y), sO=sin(aEl.y), ci=cos(aEl.x), si=sin(aEl.x);
vec3 x = vec3(cu*cO - su*ci*sO, cu*sO + su*ci*cO, su*si);
vec3 p;
if (aSM.z > 0.5) p = vec3(cos(aEl.z), sin(aEl.z), 0.)*r;
else { float cg=cos(-uGmst), sg=sin(-uGmst); p = r*vec3(x.x*cg - x.y*sg, x.x*sg + x.y*cg, x.z); }
int k = int(aSM.y+0.5);
float m = k==0?uSatMask0.x: k==1?uSatMask0.y: k==2?uSatMask0.z: k==3?uSatMask0.w: k==4?uSatMask1.x: k==5?uSatMask1.y: uSatMask1.z;
vec4 c = project(p); float occ = occluded(p);
gl_Position = c;
float sel = float(abs(float(gl_VertexID) - uSelId) < 0.5);
float base = k==6 ? 15. : k==5 ? 4.0 : k==3 ? 2.6 : k==4 ? 1.4 : 1.6;
// sunlit satellites glint; in earth shadow they dim
vec3 sd = uSun; float bb = dot(p, sd); float rho2 = dot(p,p) - bb*bb; float lit = (bb < 0. && rho2 < 1.) ? 0.25 : 1.;
float sz = max(base*uSizeK*(1.+sel), uMinSize);
gl_PointSize = (1.-occ)*m*sz*uPx;
vec3 col = k==6 ? vec3(1.0,0.9,0.7) : k==5 ? vec3(0.95,0.9,1.0) : k==4 ? vec3(0.7,0.62,0.7) : k==3 ? vec3(0.85,0.7,1.0) : k==2 ? vec3(0.7,0.85,1.0) : vec3(0.79,0.66,1.0);
vCol = vec4(col*lit, m*(1.-occ)*uAlphaK*(k==6||k==5 ? 1. : k==3 ? 0.7 : 0.42));
vGlyph = k==6 ? 7. : 0.; vSize = sz*uPx; vSeed = aSM.w; vHeat = 0.2; vAng = 0.;
}`;
SRC.sun_uniform = `uniform vec3 uSun;`;
SRC.sprite_fs = `#version 300 es
precision highp float;
in vec4 vCol; in float vGlyph; in float vAng; in float vSize; in float vSeed; in float vHeat;
uniform float uT; uniform float uPass; uniform float uWall; uniform float uGlyphPx;
out vec4 frag;
${GLSL_COMMON}
float sdSeg(vec2 p, vec2 a, vec2 b){ vec2 pa=p-a, ba=b-a; float h=clamp(dot(pa,ba)/dot(ba,ba),0.,1.); return length(pa-ba*h); }
void main(){
vec2 q = gl_PointCoord*2.-1.; q.y = -q.y;
float r = length(q);
int g = int(vGlyph+0.5);
float px = 2.5/max(vSize,1.);
float a = 0.; vec3 col = vCol.rgb; float glow = 0.;
if (vSize < uGlyphPx && g != 2 && g != 11 && g != 10 && g != 9 && g != 6 && g != 14 && g != 12) g = 0;
float ca = cos(vAng - 1.5708), sa = sin(vAng - 1.5708);
vec2 rq = vec2(ca*q.x + sa*q.y, -sa*q.x + ca*q.y);
if (g == 0) { a = exp(-r*r*7.)*1.0 + exp(-r*r*2.2)*0.18; }
else if (g == 1) { // aircraft
vec2 p = rq*1.15;
float fus = sdSeg(p, vec2(0.,-0.72), vec2(0.,0.78)) - 0.085;
float wing = max(abs(p.y + 0.02 + abs(p.x)*0.42) - 0.075, abs(p.x) - 0.86);
float tail = max(abs(p.y + 0.66 + abs(p.x)*0.3) - 0.05, abs(p.x) - 0.34);
float d = min(fus, min(wing, tail));
a = (smoothstep(px, -px, d) + exp(-r*r*3.)*0.22)*0.85;
}
else if (g == 2) { // quake ring: vSeed = age (s)
float age = vSeed; float ph = fract(uT/2.4);
float rr = 0.25 + 0.7*ph;
float pulse = exp(-pow((r-rr)/0.05,2.)) * (1.-ph) * step(age, 86400.*1.5);
float base = exp(-pow((r-0.55)/0.035,2.))*0.55 + exp(-r*r*40.)*1.2;
a = (pulse + base) * smoothstep(1.,0.9,r);
}
else if (g == 3) { // fire
float fl = 0.7 + 0.3*sin(uT*7. + vSeed*30.) * sin(uT*3.1 + vSeed*11.);
a = (exp(-r*r*9.)*1.3 + exp(-r*r*2.5)*0.35) * fl;
col = mix(vec3(1.0,0.35,0.08), vec3(1.0,0.85,0.45), exp(-r*r*14.));
}
else if (g == 4) { // ship hull
vec2 p = rq*1.2;
float d = max(abs(p.x) - 0.26 + max(p.y-0.35,0.)*0.55, abs(p.y) - 0.8);
a = smoothstep(px, -px, d) + exp(-r*r*4.)*0.15;
}
else if (g == 5) { // camera eye
float ring = abs(r - 0.62) - 0.1;
float lid = abs(length(q*vec2(1.,1.9)) - 0.86) - 0.07;
float d = min(ring, r - 0.26);
a = smoothstep(px, -px, min(d, lid)) * 0.95 + exp(-r*r*3.)*0.18;
}
else if (g == 6) { // signed capture / hologram seal
float dia = abs(q.x)+abs(q.y);
float d = min(abs(dia - 0.62) - 0.09, dia - 0.22);
float beam = exp(-q.x*q.x*80.) * smoothstep(-1., 0., -q.y) * 0.0;
a = smoothstep(px, -px, d) + exp(-r*r*3.)*0.35 + beam;
}
else if (g == 7) { // satellite / station
vec2 p = q*1.25;
float body = max(abs(p.x)-0.16, abs(p.y)-0.16);
float panel = max(abs(abs(p.x)-0.58)-0.3, abs(p.y)-0.13);
float truss = max(abs(p.x)-0.9, abs(p.y)-0.03);
a = smoothstep(px, -px, min(min(body,panel),truss)) + exp(-r*r*3.)*0.35;
}
else if (g == 8) { // helicopter
vec2 p = rq*1.2;
float rotor = abs(r*1.2 - 0.82) - 0.03;
float spin = abs(sin(atan(q.y,q.x)*2. + uT*40.));
float blades = (r < 0.95) ? smoothstep(0.9, 1., spin) * 0.55 : 0.;
float body = length(p*vec2(1.7,1.)) - 0.32;
float boom = sdSeg(p, vec2(0.,-0.1), vec2(0.,-0.82)) - 0.05;
a = max(smoothstep(px, -px, min(body, boom)), blades) + smoothstep(px,-px,rotor)*0.35 + exp(-r*r*3.)*0.18;
}
else if (g == 9) { // situation pin
float ph = fract(uT*0.5 + vSeed);
a = smoothstep(px,-px, abs(r-0.42)-0.07) + exp(-r*r*30.)*1.4 + exp(-pow((r-0.4-ph*0.55)/0.06,2.))*(1.-ph)*0.8;
}
else if (g == 10) { // hex
vec2 p = abs(q); float d = max(p.x*0.866 + p.y*0.5, p.y) - 0.86;
a = smoothstep(px*2., -px*2., d) * 0.5 + smoothstep(px,-px, abs(d+0.02)-0.03)*0.6;
}
else if (g == 11) { // soft blob (smoke / air quality / radar)
float n = vnoise(q*2.5 + vSeed*13. + uT*0.02);
a = exp(-r*r*2.6) * (0.55+0.45*n);
}
else if (g == 12) { // lightning
float fl = step(0.93, fract(sin(floor(uT*3.+vSeed*40.)*91.7)*437.1));
a = (exp(-r*r*18.)*2. + exp(-min(abs(q.x),abs(q.y))*30.)*exp(-r*2.)*0.8) * fl;
}
else if (g == 13) { // infra square
vec2 p = abs(q); float d = max(p.x,p.y) - 0.55;
a = smoothstep(px,-px, abs(d)-0.09) + smoothstep(px,-px,max(p.x,p.y)-0.18) + exp(-r*r*3.)*0.15;
}
else if (g == 14) { // volcano triangle
vec2 p = q; p.y += 0.2;
float d = max(abs(p.x)*0.866 + p.y*0.5, -p.y) - 0.45;
a = smoothstep(px,-px, abs(d)-0.08) + exp(-r*r*3.)*0.2;
}
else if (g == 15) { // wind streak (oriented)
a = exp(-(rq.x*rq.x*60. + rq.y*rq.y*1.6)) * smoothstep(1.,0.4,abs(rq.y));
}
else if (g == 16) { // runner / small dot with ring
a = smoothstep(px,-px, r-0.42) + smoothstep(px,-px, abs(r-0.7)-0.06)*0.5;
}
else if (g == 17) { // drone (quad)
vec2 p = abs(rq);
float arms = sdSeg(p, vec2(0.), vec2(0.5,0.5)) - 0.06;
float rot = abs(length(p - vec2(0.55)) - 0.22) - 0.05;
a = smoothstep(px,-px, min(arms, rot)) + exp(-r*r*4.)*0.2;
}
if (r > 1.) a = 0.;
a *= vCol.a;
if (a < 0.003) discard;
if (uPass > 0.5) frag = vec4(col, a*0.85); // alpha-blended pass
else frag = vec4(col*a, vHeat*a); // additive pass
}`;
/* ---------- lines (world space, polyline strips) ---------- */
SRC.line_vs = `#version 300 es
precision highp float;
in vec3 aPos; in vec4 aCol; in vec2 aD; // dist along (units), dashed flag
uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR; uniform float uAlphaK;
out vec4 vCol; out vec2 vD;
void main(){
vec3 E = uEyeH + uEyeL; vec3 d = aPos - E; float L = length(d); vec3 u = d/L;
float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c; float occ = 0.;
if (disc > 0.) { float tn = -b - sqrt(disc); occ = (tn > 0. && tn < L) ? 1. : 0.; }
vec4 cp = uVP*vec4((aPos-uEyeH)-uEyeL, 1.);
if (cp.z > cp.w) cp.z = cp.w*0.99999;
gl_Position = cp;
vCol = aCol; vCol.a *= (1.-occ*0.92)*uAlphaK; vD = aD;
}`;
SRC.line_fs = `#version 300 es
precision highp float;
in vec4 vCol; in vec2 vD; uniform float uDashK; uniform float uT; out vec4 frag;
void main(){
float a = vCol.a;
if (vD.y > 0.5 && fract(vD.x*uDashK - uT*0.0) > 0.55) discard;
if (vD.y > 1.5) a *= 0.5 + 0.5*sin(vD.x*uDashK*6.2832 - uT*3.);
frag = vec4(vCol.rgb*a, a*0.3);
}`;
/* ---------- meshes (local ENU meters, camera-relative matrix) ---------- */
SRC.mesh_vs = `#version 300 es
precision highp float;
in vec3 aPos; in vec3 aNor; in vec2 aUV; in vec4 aAttr; // type, hash, height, misc
uniform mat4 uMVP; uniform float uT; uniform vec4 uFlood; // x: tHours, y: start h, z: speed m/h, w: crest m
out vec3 vPos; out vec3 vNor; out vec2 vUV; out vec4 vAttr;
void main(){
vec3 p = aPos;
if (aAttr.x > 1.5 && aAttr.x < 2.5) { // flood water: rise with crest arrival
float s = aUV.x; float arr = uFlood.y + s/uFlood.z;
float dt = uFlood.x - arr;
float crest = uFlood.w * (dt > 0. ? exp(-dt/5.) * smoothstep(0., 0.4, dt) : exp(-dt*dt*6.)) ;
p.z += crest * 0.5;
}
vPos = p; vNor = aNor; vUV = aUV; vAttr = aAttr;
gl_Position = uMVP*vec4(p, 1.);
}`;
SRC.mesh_fs = `#version 300 es
precision highp float;
in vec3 vPos; in vec3 vNor; in vec2 vUV; in vec4 vAttr;
uniform vec3 uSunL; uniform vec3 uSunCol; uniform vec3 uSkyCol; uniform vec3 uFogCol; uniform vec3 uEyeL;
uniform float uNight; uniform float uT; uniform float uVis; uniform vec4 uFlood; uniform float uClear;
out vec4 frag;
${GLSL_COMMON}
void main(){
int type = int(vAttr.x+0.5);
vec3 n = normalize(vNor);
vec3 v = normalize(uEyeL - vPos);
float diff = max(dot(n, uSunL), 0.);
vec3 base; vec3 emis = vec3(0.); float heat = 0.35; float spec = 0.;
if (type == 0) { // building
float h = vAttr.y;
base = mix(vec3(0.34,0.35,0.37), vec3(0.62,0.57,0.5), h);
base = mix(base, vec3(0.2,0.25,0.3), step(0.7, h)); // glass towers
if (abs(n.z) < 0.5) {
vec2 f = vec2(vUV.x/3.2, vUV.y/3.6);
vec2 id = floor(f); vec2 fr = fract(f);
float win = step(0.18, fr.x)*step(fr.x,0.82)*step(0.22,fr.y)*step(fr.y,0.78);
float r = hash12(id + h*91.);
float lit = step(1. - (0.12 + 0.5*uNight), r) * win * step(vUV.y, vAttr.z - 1.);
vec3 wc = mix(vec3(1.0,0.72,0.42), vec3(0.75,0.85,1.0), step(0.6, hash12(id*1.3+h)));
emis += wc * lit * (0.9 + 0.6*r) * uNight * 2.2;
base = mix(base, base*0.45 + uSkyCol*0.08, win*0.8);
spec = win*(1.-uNight)*0.6;
} else base *= 0.82;
float ao = smoothstep(0., 14., vUV.y + (1.-abs(n.z))*0.) * 0.35 + 0.65;
if (abs(n.z) < 0.5) base *= ao;
heat = 0.4 + 0.3*uNight;
} else if (type == 1) { // terrain
float hgt = vAttr.z; float slope = 1.-n.z;
vec3 forest = vec3(0.035,0.075,0.025), meadow = vec3(0.15,0.17,0.07), rock = vec3(0.22,0.2,0.18), snow = vec3(0.85,0.88,0.93);
float nz = vnoise(vPos.xy*0.004)*0.5 + vnoise(vPos.xy*0.02)*0.25 + vnoise(vPos.xy*0.09)*0.12;
base = mix(forest, meadow, smoothstep(3000., 3700., hgt + nz*500.));
base = mix(base, forest*0.8, smoothstep(0.2, 0.45, slope) * (1. - smoothstep(3200., 3800., hgt)));
base = mix(base, rock, clamp(smoothstep(0.55, 0.85, slope + nz*0.25) + smoothstep(3900., 4500., hgt + nz*400.), 0., 1.));
base = mix(base, snow, smoothstep(4700., 5100., hgt + nz*700. - slope*500.));
// flood scar (mud) along the channel once the crest has passed
float scar = vAttr.w; // 0..1 near channel
float s = vUV.x; float arr = uFlood.y + s/uFlood.z;
float passed = smoothstep(0., 0.6, uFlood.x - arr);
base = mix(base, vec3(0.16,0.14,0.11), scar*passed*0.8);
heat = 0.3 + 0.2*diff;
} else if (type == 2) { // water (river / flood)
float s = vUV.x; float across = abs(vUV.y);
float arr = uFlood.y + s/uFlood.z; float dt = uFlood.x - arr;
float w = 0.18 + 0.82*(dt > 0. ? exp(-dt/6.)*smoothstep(0., 0.3, dt) : exp(-dt*dt*5.));
if (across > w) discard;
float flow = vnoise(vec2(s*0.02 - uT*0.9, vUV.y*6.));
base = mix(vec3(0.25,0.22,0.17), vec3(0.45,0.38,0.28), flow) * mix(1., 1.25, step(0.08, w-0.18));
spec = 0.8; heat = 0.12;
emis += vec3(0.9,0.8,0.6)*smoothstep(0.85,1.,flow)*0.06*(1.-uNight);
} else if (type == 3) { // stadium seats + crowd
base = vec3(0.16,0.17,0.2);
float crowd = step(0.45, hash12(floor(vUV*vec2(1.6,1.2))));
float flash = step(0.985, hash12(floor(vUV*vec2(1.6,1.2)) + floor(uT*2.)));
emis += mix(vec3(0.5,0.6,1.0), vec3(1.0,0.95,0.9), flash) * crowd * (0.05 + flash*4.) * uNight;
heat = 0.7;
} else if (type == 4) { // flat pads, runways, pitch
float h = vAttr.y;
base = h > 0.5 ? vec3(0.06,0.2,0.05)*(0.85+0.15*step(0.5,fract(vUV.x/10.))) : vec3(0.12,0.12,0.13);
if (h < 0.5) base += vec3(0.7)*step(0.97, fract(vUV.x/30.))*step(abs(vUV.y - vAttr.w*0.5), 0.6);
float edge = step(vUV.y, 1.6) + step(vAttr.w - 1.6, vUV.y);
float dots = step(0.93, fract(vUV.x/40.));
emis += (h > 0.5 ? vec3(0.05,0.2,0.04)*0.8 : vec3(1.0,0.85,0.55)*dots*edge*2.5 + vec3(0.25,0.45,1.0)*step(0.96, fract((vUV.x+vUV.y)/23.))*0.6) * uNight;
heat = 0.45;
} else if (type == 5) { // stage / light rig / beacon
base = vec3(0.05);
emis = mix(vec3(0.9,0.3,1.0), vec3(0.3,0.8,1.0), 0.5+0.5*sin(uT*1.7+vPos.x*0.05)) * 3.0;
heat = 0.9;
} else { // houses
base = mix(vec3(0.55,0.45,0.38), vec3(0.75,0.72,0.66), vAttr.y);
if (n.z > 0.5) base = mix(vec3(0.45,0.2,0.15), vec3(0.3,0.35,0.42), step(0.5, vAttr.y));
emis += vec3(1.0,0.7,0.4)*step(0.6, vAttr.y)*uNight*0.5*step(n.z,0.5);
}
vec3 amb = uSkyCol * (0.55 + 0.45*n.z);
vec3 col = base * (uSunCol*diff + amb);
if (spec > 0.) { vec3 hv = normalize(v + uSunL); col += uSunCol * pow(max(dot(n,hv),0.), 60.) * spec * 0.6; col += uSkyCol*spec*0.25*pow(1.-max(dot(n,v),0.),3.); }
col += emis;
float dist = length(uEyeL - vPos);
float fog = 1. - exp(-dist / uVis);
col = mix(col, uFogCol, clamp(fog, 0., 0.8));
frag = vec4(col, heat);
}`;
/* ---------- post ---------- */
SRC.bright_fs = `#version 300 es
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; uniform float uThr; out vec4 frag;
void main(){
vec3 c = vec3(0.);
c += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb;
c += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb;
c *= 0.25;
float l = max(c.r, max(c.g, c.b));
float k = clamp(l - uThr*0.6, 0., uThr*0.8); k = k*k/(4.*uThr*0.8+1e-4);
float w = max(k, l - uThr) / max(l, 1e-4);
frag = vec4(min(c*w, vec3(60.)), 1.);
}`;
SRC.down_fs = `#version 300 es
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; out vec4 frag;
void main(){
vec3 s = texture(uSrc, vUv).rgb*4.;
s += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb;
s += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb;
frag = vec4(s/8., 1.);
}`;
SRC.up_fs = `#version 300 es
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform sampler2D uBase; uniform vec2 uTexel; uniform float uMix; out vec4 frag;
void main(){
vec3 s = vec3(0.); vec2 o = uTexel;
s += texture(uSrc, vUv + vec2(-o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,o.y)).rgb*2.;
s += texture(uSrc, vUv + vec2(0.,o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(o.x,o.y)).rgb*2.;
s += texture(uSrc, vUv + vec2(o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(o.x,-o.y)).rgb*2.;
s += texture(uSrc, vUv + vec2(0.,-o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,-o.y)).rgb*2.;
frag = vec4(s/12. + texture(uBase, vUv).rgb*uMix, 1.);
}`;
SRC.comp_fs = `#version 300 es
precision highp float; in vec2 vUv; out vec4 frag;
uniform sampler2D uScene, uBloom; uniform vec2 uTexel; uniform float uExposure, uBloomK, uVig, uGrain, uT, uFade, uFxaa, uSat;
uniform int uMode; uniform vec3 uLift; uniform vec3 uGain;
${GLSL_COMMON}
vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14),0.,1.); }
float luma(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }
vec3 ironbow(float t){
t = clamp(t,0.,1.);
vec3 a = vec3(0.0,0.0,0.05), b = vec3(0.32,0.0,0.55), c = vec3(0.86,0.12,0.25), d = vec3(1.0,0.6,0.0), e = vec3(1.0,1.0,0.85);
if (t < 0.25) return mix(a,b,t/0.25); if (t < 0.5) return mix(b,c,(t-0.25)/0.25); if (t < 0.78) return mix(c,d,(t-0.5)/0.28); return mix(d,e,(t-0.78)/0.22);
}
vec4 sampleScene(vec2 uv){
vec4 c = texture(uScene, uv);
if (uFxaa > 0.5) {
float lN = luma(texture(uScene, uv + vec2(0., uTexel.y)).rgb), lS = luma(texture(uScene, uv - vec2(0., uTexel.y)).rgb);
float lE = luma(texture(uScene, uv + vec2(uTexel.x, 0.)).rgb), lW = luma(texture(uScene, uv - vec2(uTexel.x, 0.)).rgb);
float lM = luma(c.rgb);
float mn = min(lM, min(min(lN,lS), min(lE,lW))), mx = max(lM, max(max(lN,lS), max(lE,lW)));
if (mx - mn > max(0.06, mx*0.15)) {
vec2 dir = vec2(-((lN)-(lS)), (lE)-(lW));
float rcp = 1./(min(abs(dir.x),abs(dir.y)) + 1e-3*(lN+lS+lE+lW) + 1e-4);
dir = clamp(dir*rcp, -4., 4.) * uTexel;
vec4 a = 0.5*(texture(uScene, uv + dir*(1./3.-0.5)) + texture(uScene, uv + dir*(2./3.-0.5)));
c = vec4(a.rgb, c.a);
}
}
return c;
}
void main(){
vec4 s = sampleScene(vUv);
vec3 b = texture(uBloom, vUv).rgb;
vec3 c = (s.rgb + b*uBloomK) * uExposure;
float heat = s.a;
vec2 cc = vUv - 0.5;
float vig = 1. - dot(cc, cc) * uVig;
if (uMode == 3) { // thermal
float hv = heat*0.85 + luma(c)*0.12 + luma(b)*0.05;
c = ironbow(hv) * 0.95;
} else if (uMode == 4) { // NVG
float l = luma(aces(c*2.2));
l = pow(l, 0.8) + (hash12(vUv*vec2(1311.,977.) + fract(uT)*91.)-0.5)*0.12;
l *= 0.92 + 0.08*sin(vUv.y*900.);
c = vec3(0.25, 1.0, 0.35) * l * 1.1 + vec3(0.0,0.02,0.0);
float circ = smoothstep(0.62, 0.48, length(cc*vec2(1.,1.)));
c *= mix(0.15, 1., circ);
} else {
c = aces(c);
float l = luma(c);
c = mix(vec3(l), c, uSat);
c = c*uGain + uLift*(1.-c);
if (uMode == 2) c = mix(c, c*vec3(0.75,0.85,1.15), 0.5);
}
c *= vig;
c = pow(max(c, 0.), vec3(1./2.2));
c += (hash12(vUv*vec2(1717.,1313.) + fract(uT*0.37)*77.) - 0.5) * uGrain;
c += (hash12(vUv*vec2(911.,733.)) - 0.5)/255.;
frag = vec4(c * uFade, 1.);
}`;
SRC.trail_fs = `#version 300 es
precision highp float; in vec4 vCol; in float vK; out vec4 frag;
void main(){ float a = vCol.a; if (a < 0.002) discard; frag = vec4(vCol.rgb*a, a*0.3); }`;
SRC.copy_fs = `#version 300 es
precision highp float; in vec2 vUv; uniform sampler2D uSrc; out vec4 frag; void main(){ frag = texture(uSrc, vUv); }`;
/* ---------- GL helpers ---------- */
GL.init = function (canvas) {
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, preserveDrawingBuffer: false, powerPreference: 'high-performance', desynchronized: false });
if (!gl) return null;
GL.gl = gl;
GL.hdr = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');
GL.aniso = gl.getExtension('EXT_texture_filter_anisotropic');
gl.getExtension('OES_texture_float_linear');
GL.vao0 = gl.createVertexArray();
GL.progs = {};
const P = (name, vs, fs) => (GL.progs[name] = GL.program(vs, fs, name));
P('globe', SRC.fsq_vs, SRC.globe_fs);
P('sprite', SRC.sprite_vs, SRC.sprite_fs);
P('flight', SRC.flight_vs, SRC.sprite_fs);
P('sat', SRC.sat_vs, SRC.sprite_fs);
P('trail', SRC.flight_vs, SRC.trail_fs);
P('line', SRC.line_vs, SRC.line_fs);
P('mesh', SRC.mesh_vs, SRC.mesh_fs);
P('bright', SRC.fsq_vs, SRC.bright_fs);
P('down', SRC.fsq_vs, SRC.down_fs);
P('up', SRC.fsq_vs, SRC.up_fs);
P('comp', SRC.fsq_vs, SRC.comp_fs);
P('copy', SRC.fsq_vs, SRC.copy_fs);
return gl;
};
GL.program = function (vsSrc, fsSrc, name) {
const gl = GL.gl;
const mk = (type, src) => {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(s);
const lines = src.split('\n').map((l, i) => i + 1 + ': ' + l);
const m = /0:(\d+)/.exec(log || ''); const ln = m ? +m[1] : 0;
console.error(`[${name}] shader error`, log, '\n', lines.slice(Math.max(0, ln - 4), ln + 2).join('\n'));
throw new Error('shader compile failed: ' + name);
}
return s;
};
const p = gl.createProgram();
gl.attachShader(p, mk(gl.VERTEX_SHADER, vsSrc)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error(`[${name}] link`, gl.getProgramInfoLog(p)); throw new Error('link failed ' + name); }
const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const inf = gl.getActiveUniform(p, i); u[inf.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(p, inf.name); }
const a = {}, na = gl.getProgramParameter(p, gl.ACTIVE_ATTRIBUTES);
for (let i = 0; i < na; i++) { const inf = gl.getActiveAttrib(p, i); a[inf.name] = gl.getAttribLocation(p, inf.name); }
return { p, u, a, name };
};
GL.texture = function (img, { mips = true, wrap = 'repeat', aniso = 8 } = {}) {
const gl = GL.gl, t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap === 'repeat' ? gl.REPEAT : gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
if (mips) gl.generateMipmap(gl.TEXTURE_2D);
if (GL.aniso && aniso) gl.texParameterf(gl.TEXTURE_2D, GL.aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(aniso, gl.getParameter(GL.aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
return t;
};
GL.target = function (w, h, { hdr = true, depth = false, linear = true } = {}) {
const gl = GL.gl;
w = Math.max(1, w | 0); h = Math.max(1, h | 0);
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
const useHdr = hdr && GL.hdr;
gl.texImage2D(gl.TEXTURE_2D, 0, useHdr ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, useHdr ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
let rb = null;
if (depth) {
rb = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, w, h);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb);
}
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (!ok && useHdr) { GL.hdr = false; return GL.target(w, h, { hdr: false, depth, linear }); }
return { tex, fb, rb, w, h, dispose() { gl.deleteTexture(tex); gl.deleteFramebuffer(fb); if (rb) gl.deleteRenderbuffer(rb); } };
};
// chain of render targets for a given output size
GL.chain = function (w, h) {
const s = GL.target(w, h, { depth: true });
const lv = [];
let cw = Math.max(1, (w / 2) | 0), ch = Math.max(1, (h / 2) | 0);
for (let i = 0; i < 4; i++) { lv.push({ a: GL.target(cw, ch), b: null }); cw = Math.max(1, (cw / 2) | 0); ch = Math.max(1, (ch / 2) | 0); }
for (let i = 0; i < 3; i++) lv[i].b = GL.target(lv[i].a.w, lv[i].a.h);
return { w, h, scene: s, lv, dispose() { s.dispose(); lv.forEach((l) => { l.a.dispose(); l.b && l.b.dispose(); }); } };
};
GL.fsq = function (prog) { const gl = GL.gl; gl.useProgram(prog.p); gl.bindVertexArray(GL.vao0); gl.drawArrays(gl.TRIANGLES, 0, 3); };
GL.bindTex = function (prog, name, tex, unit) { const gl = GL.gl; gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(prog.u[name], unit); };
GL.buffer = function (data, usage) { const gl = GL.gl, b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, usage || gl.STATIC_DRAW); return b; };
// build a VAO from {name: [buffer, size, stride, offset, divisor]}
GL.vao = function (prog, spec, index) {
const gl = GL.gl, vao = gl.createVertexArray();
gl.bindVertexArray(vao);
for (const k in spec) {
const loc = prog.a[k]; if (loc == null || loc < 0) continue;
const [buf, size, stride, off, div] = spec[k];
gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, off * 4);
gl.vertexAttribDivisor(loc, div || 0);
}
if (index) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);
gl.bindVertexArray(null);
return vao;
};
+578
View File
@@ -0,0 +1,578 @@
/* World: time, sun, 44-layer catalog, GPU buffers, per-frame update, render, projection, picking. */
const W = (GE.world = {});
const FAM = {
sky: { name: 'Sky', color: '#7CC4FF', icon: 'airplane' },
sea: { name: 'Sea', color: '#38E1C6', icon: 'ferry' },
orbit: { name: 'Orbit', color: '#C9A7FF', icon: 'satellite' },
haz: { name: 'Hazards', color: '#FF6A3D', icon: 'flame' },
eyes: { name: 'Eyes', color: '#FFD166', icon: 'eye' },
ground: { name: 'Ground', color: '#9BE15D', icon: 'tram' },
wx: { name: 'Weather', color: '#8AB4F8', icon: 'cloud' },
infra: { name: 'Infra', color: '#B8C0CC', icon: 'bolt' },
};
GE.FAM = FAM;
const hex2rgb = (h) => [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
/* ---------- layer catalog (44) ---------- */
const L = (id, fam, name, on, icon) => ({ id, fam, name, on, icon, count: 0, status: 'live' });
W.layers = [
L('flights', 'sky', 'Commercial flights', true, 'airplane'), L('military', 'sky', 'Military aircraft', true, 'jet'), L('helis', 'sky', 'Helicopters', true, 'helicopter'),
L('ga', 'sky', 'Private / GA', false, 'propeller'), L('squawk', 'sky', 'Emergency squawks', true, 'exclamation'), L('drones', 'sky', 'Drones (Remote ID)', true, 'drone'),
L('airports', 'sky', 'Airport boards', false, 'board'), L('holding', 'sky', 'Holding patterns', true, 'loop'),
L('ais', 'sea', 'AIS vessels', true, 'ferry'), L('ports', 'sea', 'Ports', false, 'anchor'), L('tankers', 'sea', 'Tankers / cargo', false, 'drop'),
L('fishing', 'sea', 'Fishing effort', false, 'fish'), L('dark', 'sea', 'Dark vessels (AIS gaps)', false, 'eyeslash'), L('cables', 'sea', 'Submarine cables', false, 'cable'),
L('sats', 'orbit', 'Satellites (TLE)', true, 'satellite'), L('crewed', 'orbit', 'ISS & crewed', true, 'iss'), L('trains', 'orbit', 'Starlink trains', true, 'train'),
L('launches', 'orbit', 'Launches & trajectories', true, 'rocket'), L('debris', 'orbit', 'Debris', false, 'debris'), L('tracks', 'orbit', 'Ground tracks & passes', true, 'orbitpath'),
L('quakes', 'haz', 'Earthquakes', true, 'wave'), L('fires', 'haz', 'Active fires (FIRMS)', true, 'flame'), L('perims', 'haz', 'Fire perimeters', false, 'perimeter'),
L('floods', 'haz', 'Floods', true, 'drop'), L('cyclones', 'haz', 'Tropical cyclones', true, 'hurricane'), L('volcanoes', 'haz', 'Volcanoes', false, 'mountain'),
L('aq', 'haz', 'Air quality', false, 'aqi'),
L('cctv', 'eyes', 'Public CCTV', true, 'video'), L('signed', 'eyes', 'Signed captures', true, 'seal'), L('street', 'eyes', 'Street-level imagery', false, 'binoculars'),
L('holo', 'eyes', 'Holograms', true, 'cube'), L('streams', 'eyes', 'Live streams', false, 'live'),
L('transit', 'ground', 'Transit vehicles', false, 'tram'), L('traffic', 'ground', 'Traffic flow', true, 'car'), L('bikes', 'ground', 'Bike share', false, 'bike'),
L('rail', 'ground', 'Rail', false, 'rail'), L('venues', 'ground', 'Events & venues', true, 'ticket'), L('alpr', 'ground', 'ALPR cameras', false, 'plate'),
L('radar', 'wx', 'Radar', true, 'radar'), L('clouds', 'wx', 'Clouds', true, 'cloud'), L('lightning', 'wx', 'Lightning', true, 'bolt'),
L('wind', 'wx', 'Wind', false, 'wind'), L('models', 'wx', 'Forecast models', false, 'spaghetti'),
L('dc', 'infra', 'Datacenters', false, 'server'), L('dams', 'infra', 'Dams', false, 'dam'), L('power', 'infra', 'Power plants', false, 'bolt'),
L('milbase', 'infra', 'Military installations', false, 'shield'), L('gnss', 'infra', 'GNSS interference', true, 'jam'), L('imagery', 'infra', 'Recent satellite imagery', false, 'frame'),
];
W.layerById = Object.fromEntries(W.layers.map((l) => [l.id, l]));
W.on = (id) => !!W.layerById[id]?.on;
/* ---------- time ---------- */
W.offsetH = 0; W.live = true; W.simMs = NOW; W.t = 0; W.sun = astro.sun(NOW);
W.window = null;
W.getWindow = () => W.window || { start: Date.now() - 72 * 3600000, end: Date.now() + 120 * 3600000 };
W.setWindow = (w) => { W.window = w; bus.emit('window', w); };
W.setTime = function (ms) {
if (ms == null && !W.window) { W.live = true; W.offsetH = 0; }
else { const win = W.getWindow(); if (ms == null) ms = (win.start + win.end) / 2; ms = clamp(ms, win.start, win.end); W.offsetH = (ms - Date.now()) / 3600000; W.live = !W.window && Math.abs(W.offsetH) < 1 / 120; }
W.simMs = Date.now() + W.offsetH * 3600000;
bus.emit('time', W.offsetH);
};
W.timeWindow = () => ({ ...W.getWindow(), now: Date.now() });
W.userHolos = [];
/* ---------- static data for layers ---------- */
const RS = rng(77);
const near = (lat, lon, km, n, f) => { const out = []; for (let i = 0; i < n; i++) { const [a, b] = geo.dest(lat, lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f ? f(a, b, i) : [a, b]); } return out; };
const BIG_CITIES = GE.CITY_V.slice(0, 32);
const PORTS = [[51.95,4.05,'Rotterdam'],[1.26,103.84,'Singapore'],[31.36,121.6,'Shanghai'],[22.3,114.2,'Hong Kong'],[35.1,129.04,'Busan'],[25.0,55.06,'Jebel Ali'],[33.74,-118.26,'Los Angeles / Long Beach'],[53.54,9.97,'Hamburg'],[51.27,4.3,'Antwerp'],[29.6,32.55,'Suez'],[31.25,32.3,'Port Said'],[40.67,-74.05,'New York / NJ'],[-23.96,-46.3,'Santos'],[13.08,100.9,'Laem Chabang'],[22.6,120.27,'Kaohsiung'],[34.6,135.4,'Osaka'],[35.43,139.65,'Yokohama'],[-33.86,151.2,'Sydney'],[47.6,-122.35,'Seattle'],[30.6,122.08,'Ningbo'],[36.1,120.3,'Qingdao'],[9.35,-79.9,'Colón'],[8.95,-79.55,'Balboa'],[-33.9,18.43,'Cape Town'],[6.43,3.38,'Lagos'],[37.94,23.62,'Piraeus'],[44.4,8.9,'Genoa'],[43.3,5.35,'Marseille'],[29.75,-95.0,'Houston'],[19.0,72.85,'Mumbai (JNPT)']];
const VOLCANOES = [[37.75,14.99,'Etna'],[19.42,-155.29,'Kīlauea'],[31.59,130.66,'Sakurajima'],[19.02,-98.62,'Popocatépetl'],[-7.54,110.44,'Merapi'],[14.47,-90.88,'Fuego'],[-77.53,167.15,'Erebus'],[63.63,-19.62,'Eyjafjallajökull'],[63.9,-22.27,'Reykjanes'],[-0.37,-78.44,'Cotopaxi'],[56.06,160.64,'Klyuchevskoy'],[1.68,127.88,'Ibu'],[-8.06,114.24,'Ijen'],[13.26,123.69,'Mayon'],[46.2,-122.19,'St Helens'],[40.82,14.43,'Vesuvius'],[-39.42,-71.93,'Villarrica'],[-1.52,29.25,'Nyiragongo'],[-15.79,-71.86,'Sabancaya'],[38.79,15.21,'Stromboli']];
const DCS = [[39.04,-77.49,'Ashburn cluster'],[53.33,-6.4,'Dublin'],[50.11,8.68,'Frankfurt'],[1.3,103.8,'Singapore'],[52.37,4.9,'Amsterdam'],[45.6,-121.2,'The Dalles'],[33.45,-112.07,'Phoenix'],[41.6,-93.6,'Des Moines'],[35.69,139.69,'Tokyo'],[37.37,-121.92,'Santa Clara'],[59.33,18.06,'Stockholm'],[-23.5,-46.6,'São Paulo'],[19.07,72.88,'Mumbai'],[32.78,-96.8,'Dallas']];
const DAMS = [[30.82,111.0,'Three Gorges'],[36.02,-114.74,'Hoover'],[-25.41,-54.59,'Itaipu'],[23.97,32.88,'Aswan High'],[47.96,-118.98,'Grand Coulee'],[7.76,-62.99,'Guri'],[34.08,72.7,'Tarbela'],[28.27,85.37,'Rasuwagadhi Hydro (approx.)'],[11.2,35.1,'Grand Ethiopian Renaissance'],[46.23,7.37,'Grande Dixence']];
const MILB = [[31.33,-85.72,'Fort Novosel'],[36.95,-76.33,'Norfolk'],[34.68,-77.34,'Camp Lejeune'],[64.83,-147.6,'Eielson'],[13.58,144.92,'Andersen'],[26.27,127.75,'Kadena'],[49.44,7.6,'Ramstein'],[51.75,-1.58,'Brize Norton'],[37.0,35.42,'Incirlik'],[25.12,51.32,'Al Udeid'],[-7.31,72.41,'Diego Garcia'],[36.24,-115.03,'Nellis']];
const PADS = [[28.5618,-80.5772,'SLC-40, Cape Canaveral'],[34.632,-120.611,'SLC-4E, Vandenberg'],[45.92,63.34,'Baikonur'],[5.24,-52.77,'Kourou'],[40.96,100.29,'Jiuquan'],[30.4,130.97,'Tanegashima'],[13.72,80.23,'Sriharikota'],[19.61,110.95,'Wenchang'],[25.997,-97.155,'Starbase']];
const FISH = [[47,-50,6],[-12,-80,6],[18,-18,5],[40,150,7],[57,-170,5],[-45,-62,6],[64,-10,5],[10,-90,5],[-5,95,5],[20,115,5]];
const GNSS_HOT = [[59.5,24.0,4],[55.5,20.5,3.5],[34.5,33.5,3],[44.5,33.5,3],[33.5,36.0,2.5],[27.0,56.5,2.5],[69.5,30.5,2],[46.5,31.5,2.5]];
const CABLES = [
[[50.8,-1.3],[50.0,-10],[47,-30],[43,-50],[40.5,-72]], [[51.5,-9.8],[52.5,-30],[48,-50],[44.5,-63.5]], [[43.3,5.3],[38,10],[34.5,25],[31.3,32.3],[29.9,32.5],[20,38.5],[12.6,43.6],[12,52],[16,62],[19,72.8]],
[[35.4,139.6],[38,155],[42,180],[42,-150],[37,-125],[34.1,-118.4]], [[1.3,103.8],[5,112],[14,119],[22.3,114.2],[30,123],[35.1,129.0]], [[-33.9,151.2],[-25,170],[-10,-170],[10,-160],[21.3,-157.9],[34,-130],[37.7,-122.5]],
[[-23.0,-43.2],[-15,-30],[0,-20],[15,-20],[33,-10],[38.7,-9.2]], [[51.5,-9.8],[53,-20],[60,-20],[64,-22]], [[1.3,103.8],[-5,100],[-20,105],[-32,115.8]], [[6.4,3.4],[0,0],[-10,8],[-25,12],[-34,18.4]],
];
const RAIL = [[[51.5,-0.13],[51.0,1.9],[50.85,4.35],[52.37,4.9]],[[48.85,2.35],[47.0,4.8],[45.75,4.85],[43.3,5.37]],[[52.52,13.4],[51.34,12.37],[50.11,8.68],[48.14,11.58]],[[35.68,139.77],[35.17,136.9],[34.69,135.5],[34.4,132.46],[33.59,130.4]],[[39.9,116.4],[36.65,117.0],[34.26,117.18],[32.06,118.8],[31.23,121.47]],[[40.75,-73.99],[39.95,-75.16],[39.29,-76.61],[38.9,-77.04]],[[40.42,-3.7],[41.39,2.17]],[[41.9,12.5],[43.77,11.25],[45.46,9.19]]];
function cityScatter(n, km, f) { const out = []; for (let i = 0; i < n; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [a, b] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f(a, b, c, i)); } return out; }
// holograms & signed captures (fictional authors). Some are placed on situations.
const HOLOS = [
{ lat: 28.2551, lon: 85.3712, by: 'tamang_lens', title: 'North bank, Timure — water at the road', t: -55 * 24 * 3600 + 0, sit: 'koshi' },
{ lat: 28.1629, lon: 85.3402, by: 'hydro_nerd', title: 'Syaphrubesi bridge deck, 14:31 NPT', t: -55 * 86400, sit: 'koshi' },
{ lat: 28.2802, lon: 85.3797, by: 'geodesy_dan', title: 'Rasuwagadhi — scour line photo', t: -54 * 86400, sit: 'koshi' },
{ lat: 51.5560, lon: -0.2796, by: 'row_m_seat14', title: 'Encore from block 114', t: -3600 * 20, sit: 'concert' },
{ lat: 40.7553, lon: -73.9838, by: 'chopper_cam9', title: 'Tower, 41st floor setback', t: -3600 * 9, sit: 'climb' },
{ lat: 69.65, lon: 18.96, by: 'skywatch_ana', title: 'Corona overhead, Tromsø', t: -3600 * 18, sit: 'aurora' },
{ lat: 29.93, lon: 32.56, by: 'ais_owl', title: 'Anchorage at dusk, 48 ships', t: -3600 * 7, sit: 'suez' },
{ lat: 31.276, lon: -85.713, by: 'rotorwash_ret', title: 'Downwind leg, Cairns', t: -3600 * 30, sit: 'novosel' },
];
GE.HOLOS = HOLOS;
/* ---------- buffers ---------- */
const SPR_STRIDE = 15; // pos3 col4 par4 ext4
function pushSpr(a, v, col, size, glyph, hdg = -10, t0 = -1e9, tEnd = 1e9, grow = 0, seed = 0, heat = 0.5) {
a.push(v[0], v[1], v[2], col[0], col[1], col[2], col[3] ?? 1, size, glyph, hdg, t0, tEnd, grow, seed, heat);
}
const LINE_STRIDE = 9; // pos3 col4 d2
function pushPolyline(a, pts /* [v] */, col, dashed = 0) {
let d = 0;
for (let i = 1; i < pts.length; i++) {
const p0 = pts[i - 1], p1 = pts[i];
const seg = v3.len(v3.sub(p1, p0));
a.push(p0[0], p0[1], p0[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed);
d += seg;
a.push(p1[0], p1[1], p1[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed);
}
}
function gcPoints(lat1, lon1, lat2, lon2, n, altKm = 0, lift = 0) {
const a = geo.vec(lat1, lon1), b = geo.vec(lat2, lon2), out = [];
for (let i = 0; i <= n; i++) { const t = i / n; const p = geo.slerp(a, b, t); const r = 1 + (altKm + lift * Math.sin(Math.PI * t)) / EARTH_KM; out.push(v3.scale(p, r)); }
return out;
}
function chainPoints(ll, altKm = 0, sub = 8) { const out = []; for (let i = 1; i < ll.length; i++) { const seg = gcPoints(ll[i - 1][0], ll[i - 1][1], ll[i][0], ll[i][1], sub, altKm); if (i > 1) seg.shift(); out.push(...seg); } return out; }
function circlePts(lat, lon, km, n = 48, altKm = 0) { const out = []; for (let i = 0; i <= n; i++) { const [a, b] = geo.dest(lat, lon, (i / n) * TAU, km); out.push(geo.vec(a, b, altKm)); } return out; }
W.static = {}; // id → {vao, count, pass:'add'|'alpha', prog:'sprite'|'line'}
function makeSpriteSet(id, arr, pass = 'add') {
const gl = GL.gl, P = GL.progs.sprite, buf = GL.buffer(new Float32Array(arr));
const S = SPR_STRIDE;
const vao = GL.vao(P, { aPos: [buf, 3, S, 0], aCol: [buf, 4, S, 3], aPar: [buf, 4, S, 7], aExt: [buf, 4, S, 11] });
return { vao, buf, count: arr.length / S, pass, kind: 'sprite' };
}
function makeLineSet(arr) {
const P = GL.progs.line, buf = GL.buffer(new Float32Array(arr));
const vao = GL.vao(P, { aPos: [buf, 3, LINE_STRIDE, 0], aCol: [buf, 4, LINE_STRIDE, 3], aD: [buf, 2, LINE_STRIDE, 7] });
return { vao, buf, count: arr.length / LINE_STRIDE, kind: 'line' };
}
function addSet(layer, set) { (W.static[layer] = W.static[layer] || []).push(set); }
W.buildStatic = function () {
const C = (fam, a = 1) => [...hex2rgb(FAM[fam].color), a];
// quakes
{ const a = []; for (const q of GE.QUAKES) pushSpr(a, geo.vec(q.lat, q.lon, 0.5), [1.0, 0.5, 0.3, 1], 10 + (q.mag - 2.5) * 9, 2, -10, q.t, 1e9, 0, RS(), 0.8); addSet('quakes', makeSpriteSet('quakes', a)); }
// fires (+ smoke in alpha pass)
{ const a = [], sm = []; for (const f of GE.FIRES) { pushSpr(a, geo.vec(f.lat, f.lon, 0.2), [1, 0.5, 0.15, 1], 4 + Math.sqrt(f.frp) * 0.9, 3, -10, f.t, 1e9, 0.4, RS(), 1.0); if (RS() < 0.35) pushSpr(sm, geo.vec(f.lat + 0.15, f.lon + 0.35, 2), [0.3, 0.28, 0.27, 0.32], 26 + RS() * 30, 11, -10, f.t + 3600, 1e9, 0.8, RS(), 0.5); }
addSet('fires', makeSpriteSet('fires', a)); addSet('fires', makeSpriteSet('smoke', sm, 'alpha')); }
// perimeters
{ const a = []; for (const [la, lo, , , s] of GE.FIRE_CLUSTERS) pushPolyline(a, circlePts(la, lo, s * 111 * 0.9, 40, 0.3), [1, 0.42, 0.24, 0.6], 1); addSet('perims', makeLineSet(a)); }
// floods
{ const a = [], b = []; for (const [la, lo] of [[28.21, 85.35], [23.9, 90.6], [26.2, 91.7], [9.0, 7.5], [30.0, 70.9]]) { pushSpr(a, geo.vec(la, lo, 0.2), [0.4, 0.75, 1, 1], 16, 9, -10); pushSpr(b, geo.vec(la, lo, 0.5), [0.25, 0.55, 0.95, 0.35], 60, 11); } addSet('floods', makeSpriteSet('floods', a)); addSet('floods', makeSpriteSet('floods2', b, 'alpha')); }
// volcanoes
{ const a = []; for (const [la, lo] of VOLCANOES) pushSpr(a, geo.vec(la, lo, 0.5), [1, 0.45, 0.3, 1], 13, 14); addSet('volcanoes', makeSpriteSet('v', a)); }
// air quality (alpha)
{ const a = []; for (const [la, lo] of [[28.6, 77.2], [30.6, 75], [39.9, 116.4], [23.1, 113.3], [24.9, 67], [31.5, 74.3], [6.5, 3.4], [-6.2, 106.8], [40.2, -8.1], [-12, 25], [8, 18]]) for (let i = 0; i < 6; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 260); pushSpr(a, geo.vec(x, y, 1), [0.75, 0.5, 0.25, 0.22], 70 + RS() * 60, 11, -10, -1e9, 1e9, 0, RS()); } addSet('aq', makeSpriteSet('aq', a, 'alpha')); }
// airports (boards)
{ const a = []; for (const p of GE.AP) pushSpr(a, geo.vec(p.lat, p.lon, 0.3), C('sky', 0.9), 8 + Math.sqrt(p.w) * 1.4, 13); addSet('airports', makeSpriteSet('ap', a)); }
// ports
{ const a = []; for (const [la, lo] of PORTS) pushSpr(a, geo.vec(la, lo, 0.2), C('sea'), 13, 13); addSet('ports', makeSpriteSet('ports', a)); }
// fishing effort (alpha)
{ const a = []; for (const [la, lo, s] of FISH) for (let i = 0; i < 26; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.5, lo + RS.gauss() * s, 0.2), [0.2, 0.9, 0.8, 0.12], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); addSet('fishing', makeSpriteSet('fish', a, 'alpha')); }
// dark vessels
{ const a = []; for (let i = 0; i < 40; i++) { const h = RS.pick([[26.5, 56.4], [12.5, 43.5], [36.0, 15.0], [44, 33], [1.3, 104.2], [-12, -78], [22, 119]]); pushSpr(a, geo.vec(h[0] + RS.gauss() * 1.2, h[1] + RS.gauss() * 1.5, 0.2), [1, 0.4, 0.4, 0.95], 12, 16); } addSet('dark', makeSpriteSet('dark', a)); }
// cables
{ const a = []; for (const c of CABLES) pushPolyline(a, chainPoints(c, -0.5, 10), [0.3, 0.9, 0.85, 0.35]); addSet('cables', makeLineSet(a)); }
// launches: pads + trajectory
{ const a = []; for (const [la, lo] of PADS) pushSpr(a, geo.vec(la, lo, 0.3), C('orbit'), 12, 9, -10, -1e9, 1e9, 0, RS()); addSet('launches', makeSpriteSet('pads', a));
const l = []; const tr = []; for (let i = 0; i <= 40; i++) { const f = i / 40; const [la, lo] = geo.dest(28.5618, -80.5772, 52 * D2R, f * 1900); tr.push(geo.vec(la, lo, Math.pow(f, 0.55) * 210)); } pushPolyline(l, tr, [0.85, 0.7, 1, 0.9], 1); addSet('launches', makeLineSet(l)); }
// cyclone track (past solid, forecast dashed) + forecast cone + model spaghetti
{ const a = []; const tr = GE.CYCLONE.track; const past = tr.filter((x) => x[0] <= 0).map((x) => [x[1], x[2]]); const fut = tr.filter((x) => x[0] >= 0).map((x) => [x[1], x[2]]);
pushPolyline(a, chainPoints(past, 1, 6), [1, 0.6, 0.45, 0.95]); pushPolyline(a, chainPoints(fut, 1, 6), [1, 0.75, 0.6, 0.95], 1);
// cone edges
const left = [], right = []; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h), c2 = GE.CYCLONE.at(h + 3); const br = geo.bearing(c.lat, c.lon, c2.lat, c2.lon); const r = GE.CYCLONE.cone(h); left.push(geo.vec(...geo.dest(c.lat, c.lon, br - Math.PI / 2, r), 1)); right.push(geo.vec(...geo.dest(c.lat, c.lon, br + Math.PI / 2, r), 1)); }
pushPolyline(a, left, [1, 0.75, 0.6, 0.5]); pushPolyline(a, right, [1, 0.75, 0.6, 0.5]);
const end = GE.CYCLONE.at(120); pushPolyline(a, circlePts(end.lat, end.lon, GE.CYCLONE.cone(120), 40, 1), [1, 0.75, 0.6, 0.35]);
addSet('cyclones', makeLineSet(a));
// cone fill as soft sprites
const f = []; for (let h = 3; h <= 120; h += 3) { const c = GE.CYCLONE.at(h); pushSpr(f, geo.vec(c.lat, c.lon, 1), [1, 0.55, 0.4, 0.07], 1, 11, -10, -1e9, 1e9, 0, h); f[f.length - 8] = GE.CYCLONE.cone(h); }
W.coneSprites = f; // sized per-frame (km → px)
const m = []; for (let k = 0; k < 14; k++) { const pts = []; let la = GE.CYCLONE.at(0).lat, lo = GE.CYCLONE.at(0).lon; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h); const sp = h / 120; pts.push([c.lat + Math.sin(k * 1.7) * sp * 4.5 + RS.gauss() * sp, c.lon + Math.cos(k * 2.3) * sp * 6 + RS.gauss() * sp]); } pushPolyline(m, chainPoints(pts, 1, 4), [0.6, 0.75, 1, 0.45]); }
addSet('models', makeLineSet(m)); }
// eyes
{ const a = cityScatter(320, 18, (la, lo) => la); const s = []; for (let i = 0; i < 320; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 16); pushSpr(s, geo.vec(x, y, 0.05), C('eyes'), 13, 5); }
// Midtown / Wembley / Chicago extra cameras for close-ups
for (const [la, lo, n] of [[40.7549, -73.984, 40], [51.556, -0.2796, 18], [41.8789, -87.6359, 30], [28.163, 85.34, 4]]) for (let i = 0; i < n; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, 0.15 + RS() * 1.6); pushSpr(s, geo.vec(x, y, 0.02), C('eyes'), 15, 5); }
addSet('cctv', makeSpriteSet('cctv', s)); }
{ const s = []; for (let i = 0; i < 70; i++) { const c = BIG_CITIES[(i * 7) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 30); pushSpr(s, geo.vec(x, y, 0.05), [1, 0.82, 0.4, 1], 12, 6, -10, -1e9, 1e9, 0, RS(), 0.4); } for (const hh of HOLOS) pushSpr(s, geo.vec(hh.lat, hh.lon, 0.05), [1, 0.82, 0.4, 1], 15, 6); addSet('signed', makeSpriteSet('signed', s)); }
{ const s = cityScatter(1800, 22, () => 0); const a = []; for (let i = 0; i < 1800; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 20); pushSpr(a, geo.vec(x, y, 0.02), [1, 0.9, 0.6, 0.45], 3.2, 0); } addSet('street', makeSpriteSet('street', a)); }
{ const a = [], l = []; for (const hh of HOLOS) { pushSpr(a, geo.vec(hh.lat, hh.lon, 0.35), [1, 0.85, 0.45, 1], 22, 6, -10, -1e9, 1e9, 0, RS(), 0.4); pushPolyline(l, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.35)], [1, 0.85, 0.5, 0.9]); } addSet('holo', makeSpriteSet('holo', a)); addSet('holo', makeLineSet(l)); }
{ const a = []; for (let i = 0; i < 36; i++) { const c = BIG_CITIES[(i * 5) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 25); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.35, 0.4, 1], 12, 9, -10, -1e9, 1e9, 0, RS()); } addSet('streams', makeSpriteSet('streams', a)); }
// ground
{ const a = []; for (let i = 0; i < 1500; i++) { const c = BIG_CITIES[i % 20]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 14); pushSpr(a, geo.vec(x, y, 0.02), C('ground', 0.9), 3.5, 0); } addSet('bikes', makeSpriteSet('bikes', a)); }
{ const a = []; for (const r of RAIL) pushPolyline(a, chainPoints(r, 0.1, 6), [0.62, 0.88, 0.37, 0.65]); addSet('rail', makeLineSet(a)); }
{ const a = []; for (const [la, lo] of [[51.556, -0.2796], [40.7505, -73.9934], [41.8623, -87.6167], [34.0141, -118.2879], [48.8414, 2.253], [40.4531, -3.6883], [35.6681, 139.7], [-33.891, 151.2249], [53.4631, -2.2913], [41.3809, 2.1228]]) pushSpr(a, geo.vec(la, lo, 0.1), C('ground'), 14, 9, -10, -1e9, 1e9, 0, RS()); addSet('venues', makeSpriteSet('venues', a)); }
{ const a = []; for (let i = 0; i < 300; i++) { const c = BIG_CITIES[[0, 15, 16, 26, 24][i % 5]]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 30); pushSpr(a, geo.vec(x, y, 0.02), [0.75, 0.9, 0.6, 0.85], 7, 13); } addSet('alpr', makeSpriteSet('alpr', a)); }
// weather: radar blobs + lightning
{ const a = [], b = []; const cy = GE.CYCLONE.at(0);
for (let i = 0; i < 70; i++) { const ang = RS() * TAU, rr = Math.pow(RS(), 0.7) * 480; const [x, y] = geo.dest(cy.lat, cy.lon, ang, rr); pushSpr(a, geo.vec(x, y, 1), rr < 150 ? [1, 0.75, 0.2, 0.16] : [0.3, 0.85, 0.4, 0.11], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); if (RS() < 0.5) pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 18, 12, -10, -1e9, 1e9, 0, RS(), 0.9); }
for (const [la, lo] of [[45, -95], [5, 20], [-5, -60], [10, 100], [32, -85]]) for (let i = 0; i < 18; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 500); pushSpr(a, geo.vec(x, y, 1), [0.3, 0.85, 0.4, 0.1], 24 + RS() * 30, 11, -10, -1e9, 1e9, 0, RS()); pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 14, 12, -10, -1e9, 1e9, 0, RS(), 0.9); }
addSet('radar', makeSpriteSet('radar', a, 'alpha')); addSet('lightning', makeSpriteSet('lightning', b)); }
// infra
{ const a = []; for (const [la, lo] of DCS) pushSpr(a, geo.vec(la, lo, 0.1), C('infra'), 13, 13); addSet('dc', makeSpriteSet('dc', a)); }
{ const a = []; for (const [la, lo] of DAMS) pushSpr(a, geo.vec(la, lo, 0.1), [0.6, 0.8, 1, 1], 13, 13); addSet('dams', makeSpriteSet('dams', a)); }
{ const a = []; for (let i = 0; i < 260; i++) { const c = RS.pick(GE.AP); const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, 20 + RS() * 120); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.85, 0.5, 0.8], 6, 0); } addSet('power', makeSpriteSet('power', a)); }
{ const a = []; for (const [la, lo] of MILB) pushSpr(a, geo.vec(la, lo, 0.1), [1, 0.6, 0.4, 0.9], 13, 13); addSet('milbase', makeSpriteSet('mb', a)); }
{ const a = []; for (const [la, lo, s] of GNSS_HOT) for (let i = 0; i < 9; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.25, lo + RS.gauss() * s * 0.45, 1), [1, 0.32, 0.36, 0.22], 18 + s * 4, 10, -10, -1e9, 1e9, 0, RS(), 0.6); addSet('gnss', makeSpriteSet('gnss', a, 'alpha')); }
{ const a = []; for (let i = 0; i < 40; i++) { const c = RS.pick(GE.AP); const la = c.lat + RS.gauss() * 2, lo = c.lon + RS.gauss() * 2, w = 0.5 + RS() * 0.6; const q = [[la - w, lo - w], [la - w, lo + w], [la + w, lo + w], [la + w, lo - w], [la - w, lo - w]].map(([x, y]) => geo.vec(x, y, 0.5)); pushPolyline(a, q, [0.8, 0.85, 0.95, 0.5]); } addSet('imagery', makeLineSet(a)); }
// AIS lanes as faint context lines
{ const a = []; for (const l of GE.LANES) pushPolyline(a, chainPoints(l, 0, 6), [0.22, 0.88, 0.78, 0.07]); addSet('ais', makeLineSet(a)); }
// situation pins handled dynamically
W.counts.quakes = GE.QUAKES.length; W.counts.ports = PORTS.length; W.counts.volcanoes = VOLCANOES.length; W.counts.dc = DCS.length; W.counts.dams = DAMS.length; W.counts.milbase = MILB.length; W.counts.cables = CABLES.length;
W.counts.launches = PADS.length; W.counts.cctv = 408; W.counts.signed = 78; W.counts.street = 1800; W.counts.holo = HOLOS.length; W.counts.streams = 36; W.counts.bikes = 1500; W.counts.rail = 412; W.counts.venues = 10; W.counts.alpr = 300;
W.counts.radar = 160; W.counts.clouds = 1; W.counts.models = 14; W.counts.power = 260; W.counts.gnss = 8; W.counts.imagery = 40; W.counts.fishing = 260; W.counts.dark = 40; W.counts.aq = 66; W.counts.floods = 5; W.counts.perims = GE.FIRE_CLUSTERS.length;
};
W.counts = {};
/* ---------- GPU flights & satellites ---------- */
W.buildMovers = function () {
const gl = GL.gl;
const F = GE.FLIGHTS, n = F.length, a = new Float32Array(n * 14);
F.forEach((f, i) => { const o = i * 14; a.set(f.a.v, o); a.set(f.b.v, o + 3); a[o + 6] = f.t0; a[o + 7] = f.dur; a[o + 8] = f.period; a[o + 9] = f.turn; a[o + 10] = f.alt; a[o + 11] = f.kind; a[o + 12] = hash01(i); a[o + 13] = f.dKm; });
const fb = GL.buffer(a);
const spec = (P) => ({ aA: [fb, 3, 14, 0, 1], aB: [fb, 3, 14, 3, 1], aTime: [fb, 4, 14, 6, 1], aMeta: [fb, 4, 14, 10, 1] });
W.flightVao = GL.vao(GL.progs.flight, spec());
const K = 28, kb = GL.buffer(new Float32Array(Array.from({ length: K }, (_, i) => i / (K - 1))));
const ts = spec(); ts.aK = [kb, 1, 1, 0, 0];
W.trailVao = GL.vao(GL.progs.trail, ts); W.trailK = K; W.nFlights = n;
const S = GE.SATS, sa = new Float32Array(S.length * 8);
S.forEach((s, i) => { sa.set([s.inc, s.raan, s.u0, s.n, s.alt, s.kind, s.geo ? 1 : 0, hash01(i + 7)], i * 8); });
const sb = GL.buffer(sa);
W.satVao = GL.vao(GL.progs.sat, { aEl: [sb, 4, 8, 0], aSM: [sb, 4, 8, 4] }); W.nSats = S.length;
// dynamic sprites / lines
W.dynBuf = gl.createBuffer(); W.dynVao = null; W.dynCount = 0; W.dynCap = 0;
W.dynLineBuf = gl.createBuffer(); W.dynLineCount = 0; W.dynLineCap = 0;
W.alphaBuf = gl.createBuffer(); W.alphaCount = 0; W.alphaCap = 0;
};
function uploadDyn(bufKey, vaoKey, capKey, countKey, arr, kind) {
const gl = GL.gl; const f = new Float32Array(arr);
gl.bindBuffer(gl.ARRAY_BUFFER, W[bufKey]);
if (f.length > W[capKey]) { W[capKey] = Math.max(f.length, W[capKey] * 1.5) | 0; gl.bufferData(gl.ARRAY_BUFFER, W[capKey] * 4, gl.DYNAMIC_DRAW);
if (kind === 'line') W[vaoKey] = GL.vao(GL.progs.line, { aPos: [W[bufKey], 3, LINE_STRIDE, 0], aCol: [W[bufKey], 4, LINE_STRIDE, 3], aD: [W[bufKey], 2, LINE_STRIDE, 7] });
else W[vaoKey] = GL.vao(GL.progs.sprite, { aPos: [W[bufKey], 3, SPR_STRIDE, 0], aCol: [W[bufKey], 4, SPR_STRIDE, 3], aPar: [W[bufKey], 4, SPR_STRIDE, 7], aExt: [W[bufKey], 4, SPR_STRIDE, 11] }); }
gl.bufferSubData(gl.ARRAY_BUFFER, 0, f);
W[countKey] = f.length / (kind === 'line' ? LINE_STRIDE : SPR_STRIDE);
}
/* ---------- CPU movers: helicopters, drones, holding, transit, wind, runners ---------- */
const HELI_CIRCUITS = [];
(function () {
// Fort Novosel: Cairns AAF + stagefields — rectangular circuits (illustrative)
const fields = [[31.276, -85.713, 'Cairns'], [31.36, -85.63, 'Stagefield A'], [31.20, -85.81, 'Stagefield B'], [31.42, -85.79, 'Stagefield C']];
let k = 0;
for (const [la, lo, nm] of fields) for (let i = 0; i < (nm === 'Cairns' ? 7 : 4); i++) HELI_CIRCUITS.push({ lat: la, lon: lo, w: 1.6 + RS() * 0.6, h: 0.9 + RS() * 0.3, rot: (nm === 'Cairns' ? 0.06 : RS() * 0.6), alt: 0.15 + RS() * 0.04, period: 330 + RS() * 60, ph: i / 7 + RS() * 0.05, cs: ['HAWK', 'LAKOTA', 'ROTOR', 'TALON'][k % 4] + ' ' + (21 + k), field: nm, type: k % 3 ? 'UH-72' : 'TH-73', idx: k++ });
// news helicopters over the tower (orbit), and city police/EMS
for (let i = 0; i < 3; i++) HELI_CIRCUITS.push({ orbit: true, lat: 40.7553, lon: -73.9838, r: 0.55 + i * 0.25, alt: 0.33 + i * 0.06, period: 140 + i * 30, ph: i * 0.33, cs: ['N9 NEWS', 'SKY 4', 'EYE 2'][i] + ' (illustrative)', type: 'AS350', idx: k++ });
for (let i = 0; i < 18; i++) { const c = BIG_CITIES[i]; HELI_CIRCUITS.push({ orbit: true, lat: c.lat + RS.gauss() * 0.05, lon: c.lon + RS.gauss() * 0.05, r: 1 + RS() * 3, alt: 0.3 + RS() * 0.3, period: 200 + RS() * 300, ph: RS(), cs: 'MED ' + (100 + i), type: 'EC135', idx: k++ }); }
})();
GE.HELI_CIRCUITS = HELI_CIRCUITS;
function heliAt(hc, t) {
const ph = ((t / hc.period + hc.ph) % 1 + 1) % 1;
if (hc.orbit) { const ang = ph * TAU; const [la, lo] = geo.dest(hc.lat, hc.lon, ang, hc.r); return { lat: la, lon: lo, altKm: hc.alt, hdg: ang + Math.PI / 2, v: geo.vec(la, lo, hc.alt), spd: (TAU * hc.r * 1000) / hc.period * 1.944 }; }
// rectangle circuit: perimeter param
const W2 = hc.w, H2 = hc.h, P = 2 * (W2 + H2); let d = ph * P; let x, y, hd;
if (d < W2) { x = d - W2 / 2; y = -H2 / 2; hd = Math.PI / 2; } else if ((d -= W2) < H2) { x = W2 / 2; y = d - H2 / 2; hd = 0; } else if ((d -= H2) < W2) { x = W2 / 2 - d; y = H2 / 2; hd = -Math.PI / 2; } else { d -= W2; x = -W2 / 2; y = H2 / 2 - d; hd = Math.PI; }
// round corners a bit
const c = Math.cos(hc.rot), s = Math.sin(hc.rot); const ex = x * c - y * s, ny = x * s + y * c;
const [la, lo] = geo.offset(hc.lat, hc.lon, ex * 1000, ny * 1000);
// altitude: descend on final (last quarter), climb after takeoff
const alt = hc.alt * (0.35 + 0.65 * smooth(0.0, 0.12, ph) * smooth(1.0, 0.86, ph));
return { lat: la, lon: lo, altKm: alt, hdg: hd + hc.rot * -1, v: geo.vec(la, lo, alt), spd: (P * 1000 / hc.period) * 1.944 };
}
GE.heliAt = heliAt;
const HOLD_STACKS = [[51.72, -0.55, 'BNN'], [51.30, -0.45, 'OCK'], [51.33, 0.03, 'BIG'], [51.65, 0.15, 'LAM'], [40.9, -73.2, 'CCC'], [33.9, -84.0, 'ATL-E']];
const HOLDERS = []; HOLD_STACKS.forEach(([la, lo, nm], si) => { for (let i = 0; i < (si < 4 ? 5 : 3); i++) HOLDERS.push({ la, lo, nm, lvl: 7 + i * 1, ph: RS(), cs: GE.FLIGHTS[(si * 13 + i * 7) % 3000].cs }); });
function holdAt(hd, t) {
const per = 240, ph = ((t / per + hd.ph) % 1 + 1) % 1; // racetrack 4 min
const L2 = 7, Rr = 2.2; const P = 2 * L2 + TAU * Rr; let d = ph * P; let x, y, hh;
if (d < L2) { x = d - L2 / 2; y = Rr; hh = Math.PI / 2; } else if ((d -= L2) < Math.PI * Rr) { const a = d / Rr; x = L2 / 2 + Math.sin(a) * Rr; y = Math.cos(a) * Rr; hh = Math.PI / 2 + a; } else if ((d -= Math.PI * Rr) < L2) { x = L2 / 2 - d; y = -Rr; hh = -Math.PI / 2; } else { d -= L2; const a = d / Rr; x = -L2 / 2 - Math.sin(a) * Rr; y = -Math.cos(a) * Rr; hh = -Math.PI / 2 + a; }
const [la, lo] = geo.offset(hd.la, hd.lo, x * 1000, y * 1000);
return { lat: la, lon: lo, altKm: hd.lvl * 0.3048, hdg: hh, v: geo.vec(la, lo, hd.lvl * 0.3048) };
}
// delivery-drone corridor (illustrative, suburban Texas)
const DRONE_DEPOT = [30.5880, -96.2900];
const DRONES = []; for (let i = 0; i < 16; i++) { const brg = RS() * TAU, km = 1.2 + RS() * 4.5; DRONES.push({ to: geo.dest(DRONE_DEPOT[0], DRONE_DEPOT[1], brg, km), km, period: 600 + RS() * 400, ph: RS(), id: 'RID-' + (4400 + i) }); }
GE.DRONES = DRONES; GE.DRONE_DEPOT = DRONE_DEPOT;
function droneAt(d, t) {
const ph = ((t / d.period + d.ph) % 1 + 1) % 1; const out = ph < 0.5; const f = out ? ph * 2 : (1 - ph) * 2;
const s = smooth(0, 1, f); const la = lerp(DRONE_DEPOT[0], d.to[0], s), lo = lerp(DRONE_DEPOT[1], d.to[1], s);
const alt = 0.06 * Math.min(1, Math.min(f, 1 - f) * 12 + 0.05);
return { lat: la, lon: lo, altKm: alt, v: geo.vec(la, lo, alt), hdg: geo.bearing(DRONE_DEPOT[0], DRONE_DEPOT[1], d.to[0], d.to[1]) + (out ? 0 : Math.PI) };
}
// marathon (fictional race) — runners on a lakeshore loop in Chicago
const RUN_ROUTE = [[41.8827, -87.6233], [41.8930, -87.6240], [41.9100, -87.6330], [41.9280, -87.6380], [41.9400, -87.6440], [41.9280, -87.6420], [41.9100, -87.6300], [41.8960, -87.6180], [41.8800, -87.6150], [41.8670, -87.6170], [41.8520, -87.6200], [41.8380, -87.6080], [41.8520, -87.6150], [41.8680, -87.6230], [41.8827, -87.6233]];
const RUN_V = chainPoints(RUN_ROUTE, 0.002, 6); const RUN_CUM = [0]; for (let i = 1; i < RUN_V.length; i++) RUN_CUM.push(RUN_CUM[i - 1] + v3.len(v3.sub(RUN_V[i], RUN_V[i - 1])));
const RUNNERS = []; for (let i = 0; i < 260; i++) RUNNERS.push({ pace: 0.9 + Math.pow(RS(), 1.6) * 1.1, off: RS() * 0.18, lane: RS.gauss() * 0.4, bib: 1000 + i });
GE.RUN_ROUTE = RUN_ROUTE;
function runnerAt(r, t) {
const L = RUN_CUM[RUN_CUM.length - 1]; let d = ((r.off + (t / 3600) * 3.2 / r.pace / (L * EARTH_KM / 1)) % 1 + 1) % 1 * L;
let i = 1; while (i < RUN_CUM.length - 1 && RUN_CUM[i] < d) i++;
const f = (d - RUN_CUM[i - 1]) / ((RUN_CUM[i] - RUN_CUM[i - 1]) || 1); const v = v3.lerp(RUN_V[i - 1], RUN_V[i], f); return v;
}
// wind particles (zonal jets + cyclone swirl)
const WIND = []; for (let i = 0; i < 1400; i++) WIND.push({ lat: Math.asin(RS() * 2 - 1) * R2D * 0.95, lon: RS() * 360 - 180, ph: RS(), life: 5400 + RS() * 5400 });
function windVel(lat, lon, t) { // deg/hour
const jet = 34 * Math.exp(-Math.pow((Math.abs(lat) - 45) / 10, 2)) - 8 * Math.exp(-Math.pow(lat / 14, 2)) + 6 * Math.sin(lon * D2R * 3 + t / 40000);
let u = jet * 3.6 / 111 / Math.max(0.2, Math.cos(lat * D2R)), v = 4 * Math.sin(lon * D2R * 2 + lat * D2R * 3) * 3.6 / 111;
const c = GE.CYCLONE.at(W.offsetH); const dlat = lat - c.lat, dlon = (lon - c.lon) * Math.cos(lat * D2R); const r = Math.hypot(dlat, dlon);
if (r < 12) { const s = 60 * Math.exp(-r / 3.5) * (1 - Math.exp(-r * r / 0.5)); u += (-dlat / (r + 1e-3)) * s * 3.6 / 111; v += (dlon / (r + 1e-3)) * s * 3.6 / 111; }
return [u, v];
}
W.squawks = [41, 1207].map((i) => i % GE.FLIGHTS.length);
W.situationPins = []; // filled by situations module
W.updateDynamic = function () {
const t = W.t, a = [], al = [], ln = [];
// ships (AIS) — color by kind when tankers layer on
const showAis = W.on('ais'), showTk = W.on('tankers');
if (showAis || showTk) {
if (!W._shipCache || Math.abs(W._shipCacheT - t) > 6 || W._shipCacheN !== GE.SHIPS.length) { W._shipCache = GE.SHIPS.map((s) => shipAt(s, t)); W._shipCacheT = t; W._shipCacheN = GE.SHIPS.length; }
GE.SHIPS.forEach((s, i) => {
const p = W._shipCache[i]; const tank = s.kind === 'tanker' || s.kind === 'LNG';
if (!showAis && !tank) return;
const ak = clamp(lerp(0.32, 0.95, smooth(1.2, 0.08, W.lastRange ?? 1)), 0.3, 1);
const col = showTk && tank ? [1, 0.7, 0.35, ak] : s.queue ? [0.5, 1, 0.9, ak] : [0.22, 0.88, 0.78, ak * 0.95];
pushSpr(a, geo.vec(p.lat, p.lon, 0.03), col, (showTk && tank ? 10 : 8.5), 4, p.hdg, -1e9, 1e9, 0, i, 0.35);
});
}
if (W.on('helis')) for (const hc of HELI_CIRCUITS) { const p = heliAt(hc, t); pushSpr(a, p.v, [0.62, 1, 0.6, 1], 17, 8, p.hdg, -1e9, 1e9, 0, hc.idx, 0.7); }
if (W.on('holding')) for (const hd of HOLDERS) { const p = holdAt(hd, t); pushSpr(a, p.v, [0.56, 0.82, 1, 1], 13, 1, p.hdg, -1e9, 1e9, 0, 0, 0.55); }
if (W.on('drones')) for (const d of DRONES) { const p = droneAt(d, t); pushSpr(a, p.v, [0.75, 1, 0.95, 1], 13, 17, p.hdg, -1e9, 1e9, 0, 0, 0.5); }
if (W.on('squawk')) for (const i of W.squawks) { const p = flightAt(GE.FLIGHTS[i], t); if (p) pushSpr(a, v3.scale(p.v, 1 + p.altKm * W.altX / EARTH_KM), [1, 0.25, 0.25, 1], 30, 9, -10, -1e9, 1e9, 0, i * 0.37, 0.8); }
if (W.on('venues') || W.on('transit')) for (const r of RUNNERS) pushSpr(a, runnerAt(r, t), [0.62, 0.9, 0.37, 1], 6, 16, -10, -1e9, 1e9, 0, 0, 0.6);
if (W.on('transit')) for (let i = 0; i < 400; i++) { const c = BIG_CITIES[i % 16]; const ang = hash01(i) * TAU + t / (600 + hash01(i + 3) * 900); const rr = 2 + hash01(i + 9) * 10; const [la, lo] = geo.dest(c.lat, c.lon, ang, rr); pushSpr(a, geo.vec(la, lo, 0.01), [0.62, 0.9, 0.37, 0.95], 5, 0); }
if (W.on('wind')) for (const w of WIND) { const age = ((t / w.life + w.ph) % 1 + 1) % 1; let la = w.lat, lo = w.lon; const steps = 4, dtH = (age * w.life) / 3600 / steps; for (let k = 0; k < steps; k++) { const [u, v] = windVel(la, lo, t); la += v * dtH; lo += u * dtH; } const [u, v] = windVel(la, lo, t); const fade = Math.sin(age * Math.PI); pushSpr(a, geo.vec(la, lo, 3), [0.75, 0.85, 1, 0.55 * fade], 14, 15, Math.atan2(u * Math.cos(la * D2R), v), -1e9, 1e9, 0, 0, 0.1); }
for (const hh of W.userHolos) pushSpr(a, geo.vec(hh.lat, hh.lon, 0.06), [1, 0.85, 0.45, 1], 24, 6, -10, -1e9, 1e9, 0, 0.2, 0.4);
if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - (Date.now() - 10.5 * 3600000)) / (1.9 * 3600000), 0, 1); const [la, lo] = geo.offset(40.7553, -73.9838, 16, 23.5); pushSpr(a, geo.vec(la, lo, (12 + f * 372) / 1000), [1, 0.3, 0.3, 1], 16, 9, -10, -1e9, 1e9, 0, 0.7, 0.9); }
// situation pins
for (const p of W.situationPins) pushSpr(a, geo.vec(p.lat, p.lon, 0.2), p.col, p.size || 16, 9, -10, -1e9, 1e9, 0, p.seed || 0, 0.6);
// selection highlight
if (W.sel && W.sel.v) pushSpr(a, W.sel.v, [1, 0.85, 0.45, 1], 34, 9, -10, -1e9, 1e9, 0, 0.5, 0.6);
// cyclone cone fill (alpha)
if (W.on('cyclones') && W.coneSprites) { const f = W.coneSprites.slice(); const kmPerPx = W.kmPerPx || 10; for (let i = 0; i < f.length; i += SPR_STRIDE) { f[i + 7] = clamp((f[i + 7] * 2) / kmPerPx, 1, 600) / Math.max(W.sizeK, 0.1); } al.push(...f); }
uploadDyn('dynBuf', 'dynVao', 'dynCap', 'dynCount', a, 'sprite');
uploadDyn('alphaBuf', 'alphaVao', 'alphaCap', 'alphaCount', al, 'sprite');
// dynamic lines: ISS ground track, selected route, extra lines from UI
if (W.on('tracks')) {
const pts = []; for (let k = -46; k <= 46; k++) { const tt = t + k * 60; const s = astro.sun(NOW + tt * 1000); const p = satAt(GE.ISS, tt, s.gmst); pts.push(v3.scale(v3.norm(p), 1.0005)); }
pushPolyline(ln, pts, [0.79, 0.66, 1, 0.55], 1);
const op = []; for (let k = 0; k <= 64; k++) { const tt = t + (k / 64) * (TAU / GE.ISS.n); op.push(satAt(GE.ISS, tt, W.sun.gmst)); } pushPolyline(ln, op, [0.79, 0.66, 1, 0.4]);
}
if (W.selRoute) pushPolyline(ln, W.selRoute, [1, 0.85, 0.45, 0.8], 1);
for (const extra of W.extraLines) pushPolyline(ln, extra.pts, extra.col, extra.dashed || 0);
for (const hh of W.userHolos) pushPolyline(ln, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.06)], [1, 0.85, 0.5, 0.9]);
uploadDyn('dynLineBuf', 'dynLineVao', 'dynLineCap', 'dynLineCount', ln, 'line');
};
W.extraLines = [];
/* ---------- counts ---------- */
W.updateCounts = function () {
const t = W.t; let c0 = 0, c1 = 0, c3 = 0;
for (const f of GE.FLIGHTS) { let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period; const lp = ph < f.period / 2 ? ph : ph - f.period / 2; if (lp <= f.dur) { if (f.kind === 0) c0++; else if (f.kind === 1) c1++; else c3++; } }
const C = W.counts;
C.flights = c0; C.military = c1; C.ga = c3; C.helis = HELI_CIRCUITS.length; C.squawk = W.squawks.length; C.drones = DRONES.length; C.airports = GE.AP.length; C.holding = HOLDERS.length;
C.ais = GE.SHIPS.length; C.tankers = GE.SHIPS.filter((s) => s.kind === 'tanker' || s.kind === 'LNG').length;
C.sats = GE.SATS.filter((s) => s.kind <= 3).length; C.crewed = 2; C.trains = 1; C.debris = GE.SATS.filter((s) => s.kind === 4).length; C.tracks = 1;
C.fires = GE.FIRES.filter((f) => f.t <= t).length; C.quakes = GE.QUAKES.filter((q) => q.t <= t && q.t > t - 86400 * 3).length;
C.cyclones = 1; C.lightning = 160 + Math.round(40 * Math.sin(t / 900)); C.wind = 1400; C.transit = 400; C.traffic = 1;
for (const l of W.layers) l.count = C[l.id] ?? 0;
bus.emit('layers', W.layers);
};
/* ---------- textures ---------- */
W.loadTextures = async function () {
const T = window.__GE_TEX || {};
const load = (src) => new Promise((res, rej) => { const im = new Image(); im.decoding = 'async'; im.onload = () => res(im); im.onerror = rej; im.src = src; });
const [day, night, clouds, water, topo] = await Promise.all(['day', 'night', 'clouds', 'water', 'topo'].map((k) => load(T[k])));
W.img = { day, night, clouds, water, topo };
W.tex = { day: GL.texture(day), night: GL.texture(night), clouds: GL.texture(clouds), water: GL.texture(water, { aniso: 2 }), topo: GL.texture(topo, { mips: true, aniso: 0 }) };
// CPU copies for density queries (lights) and thumbnails fallback
const cv = document.createElement('canvas'); cv.width = 1024; cv.height = 512; const cx = cv.getContext('2d', { willReadFrequently: true });
cx.drawImage(night, 0, 0, 1024, 512); W.nightPx = cx.getImageData(0, 0, 1024, 512).data;
cx.drawImage(water, 0, 0, 1024, 512); W.waterPx = cx.getImageData(0, 0, 1024, 512).data;
};
W.lightsAt = function (lat, lon) {
if (!W.nightPx) return 0;
const x = ((lon + 180) / 360) * 1024, y = ((90 - lat) / 180) * 512;
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
const s = (i, j) => { i = ((i % 1024) + 1024) % 1024; j = clamp(j, 0, 511); const o = (j * 1024 + i) * 4; return Math.max(0, (W.nightPx[o + 1] * 1.1 - W.nightPx[o + 2] * 0.55) / 255); };
return lerp(lerp(s(x0, y0), s(x0 + 1, y0), fx), lerp(s(x0, y0 + 1), s(x0 + 1, y0 + 1), fx), fy);
};
W.waterAt = function (lat, lon) { if (!W.waterPx) return 0; const x = Math.floor(((lon + 180) / 360) * 1024) % 1024, y = clamp(Math.floor(((90 - lat) / 180) * 512), 0, 511); return W.waterPx[(y * 1024 + x) * 4] / 255; };
/* ---------- lens ---------- */
W.lens = 'cinematic';
const LENS = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 };
W.setLens = (id) => { W.lens = id; bus.emit('lens', id); };
/* ---------- view helpers ---------- */
// view: {eye, right, up, back, fovY, aspect, near, far, shiftX, shiftY, w, h}
W.matrices = function (view) {
const P = m4.persp(view.fovY, view.aspect, view.near, view.far);
P[8] = -(view.shiftX || 0); P[9] = -(view.shiftY || 0);
const V = m4.viewRot(view.right, view.up, view.back);
view.P = P; view.V = V; view.VP = m4.mul(P, V);
return view;
};
W.project = function (view, p) { // world unit coords → css px {x,y,z,vis}
const rel = v3.sub(p, view.eye); const c = m4.xform(view.VP, rel);
if (c[3] <= 0) return null;
const x = c[0] / c[3], y = c[1] / c[3];
// occlusion by planet
const E = view.eye, d = rel, L2 = v3.len(d), u = v3.scale(d, 1 / L2), b = v3.dot(E, u), cc = v3.dot(E, E) - 0.9985 * 0.9985, disc = b * b - cc;
let occ = false; if (disc > 0) { const tn = -b - Math.sqrt(disc); occ = tn > 0 && tn < L2; }
return { x: (x * 0.5 + 0.5) * view.w, y: (1 - (y * 0.5 + 0.5)) * view.h, d: L2, vis: !occ && Math.abs(x) < 1.2 && Math.abs(y) < 1.2 };
};
/* ---------- render ---------- */
W.quality = { scale: 1, steps: 12, bloom: true, grain: true };
const LOD = { cctv: 0.07, street: 0.03, alpr: 0.035, bikes: 0.04, streams: 0.4, signed: 0.6, venues: 0.9, power: 0.7, dc: 1.6, dams: 1.6, milbase: 1.6, airports: 1.3, ports: 1.4, imagery: 1.6, fishing: 2.2, radar: 1.6 };
W.render = function (view, chain, outFb, opts = {}) {
const gl = GL.gl, PR = GL.progs;
W.matrices(view);
const sun = opts.sun || W.sun;
const sunDir = sun.dir;
const t = opts.t ?? W.t;
const eye = view.eye, eyeR = v3.len(eye), alt = eyeR - 1;
const eyeC = (eyeR - 1) * (eyeR + 1);
const eyeH = eye.map(Math.fround), eyeL = [eye[0] - eyeH[0], eye[1] - eyeH[1], eye[2] - eyeH[2]];
const lensId = opts.lens || W.lens; const mode = LENS[lensId] ?? 0;
const range = view.range ?? alt;
const tanY = Math.tan(view.fovY / 2), tanX = tanY * view.aspect;
gl.bindFramebuffer(gl.FRAMEBUFFER, chain.scene.fb);
gl.viewport(0, 0, chain.w, chain.h);
gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.depthMask(true);
gl.clearColor(0, 0, 0, 0); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// ---- globe
const G = PR.globe; gl.useProgram(G.p);
gl.uniform3fv(G.u.uCamR, view.right); gl.uniform3fv(G.u.uCamU, view.up); gl.uniform3fv(G.u.uCamB, view.back);
gl.uniform4f(G.u.uTanS, tanX, tanY, view.shiftX || 0, view.shiftY || 0);
gl.uniform3fv(G.u.uEye, eye); gl.uniform1f(G.u.uEyeC, eyeC); gl.uniform1f(G.u.uT, t); gl.uniform1f(G.u.uGmst, sun.gmst);
gl.uniform3fv(G.u.uSun, sunDir); gl.uniform1f(G.u.uSunI, 20.0);
const cloudsOn = W.on('clouds') ? 1 : 0;
gl.uniform4f(G.u.uLens, mode, mode === 2 ? 1 : opts.nightForce || 0, cloudsOn * (opts.cloudK ?? 1), mode === 1 ? 1 : 0);
// cyclone
const cyc = GE.CYCLONE.at(opts.offsetH ?? W.offsetH); const cv = geo.vec(cyc.lat, cyc.lon);
gl.uniform4f(G.u.uCyc, cv[0], cv[1], cv[2], 5.2 * D2R);
gl.uniform4f(G.u.uCycP, W.on('cyclones') ? clamp(cyc.wind / 190, 0.3, 1.0) : 0, (t / 3600) * 1.8, 0.16 * D2R, 0);
gl.uniform1f(G.u.uAurora, opts.aurora ?? W.auroraK);
// local detail frame
const sc = opts.scene || W.activeScene(view);
const org = sc ? sc : { lat: view.tLat ?? 0, lon: view.tLon ?? 0, block: 95, rot: 0.3, urban: 0 };
const enu = geo.enu(org.lat, org.lon), ov = geo.vec(org.lat, org.lon);
const de = v3.sub(eye, ov);
const eyeLocal = [v3.dot(de, enu.east) * EARTH_M, v3.dot(de, enu.north) * EARTH_M, v3.dot(de, enu.up) * EARTH_M];
gl.uniformMatrix3fv(G.u.uEnu, false, [...enu.east, ...enu.north, ...enu.up]);
gl.uniform3fv(G.u.uEyeLocal, eyeLocal);
const detail = smooth(0.012, 0.0025, range) * (opts.detail ?? 1);
gl.uniform4f(G.u.uCity, detail, org.block || 95, org.rot || 0, org.urban || 0);
gl.uniform1f(G.u.uRange, range);
gl.uniform1i(G.u.uSteps, opts.steps || W.quality.steps);
gl.uniform1f(G.u.uInside, eyeR < 1.0165 ? 1 : 0);
const fo = opts.focus; if (fo) { const fv = geo.vec(fo.lat, fo.lon); gl.uniform4f(G.u.uFocus, fv[0], fv[1], fv[2], fo.k ?? 0.8); } else gl.uniform4f(G.u.uFocus, 0, 0, 1, 0);
GL.bindTex(G, 'uDay', W.tex.day, 0); GL.bindTex(G, 'uNight', W.tex.night, 1); GL.bindTex(G, 'uClouds', W.tex.clouds, 2); GL.bindTex(G, 'uWater', W.tex.water, 3); GL.bindTex(G, 'uTopo', W.tex.topo, 4);
gl.uniform4fv(G.u.uLocalWater, sc && sc.water ? sc.water : [0, 0, 0, 0]);
gl.uniform1f(G.u.uTraffic, W.on('traffic') ? 1 : 0);
GL.fsq(G);
// ---- meshes
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL);
if (sc && sc.mesh && range < 0.03) GE.scenes.draw(sc, view, { sun, t, eye, opts });
// ---- lines & sprites (additive)
gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
const altX = lerp(1, 16, smooth(0.008, 1.3, range)); W.altX = altX;
const sizeK = opts.sizeK ?? clamp(lerp(0.5, 1.1, smooth(2.2, 0.05, range)), 0.4, 1.3); W.sizeK = sizeK; W.lastRange = range;
const px = (opts.pxScale ?? 1) * (chain.w / view.w);
const occR = 1 - clamp(alt * 0.004, 2e-7, 0.0015);
const common = (P, alphaK = 1) => {
gl.useProgram(P.p);
gl.uniformMatrix4fv(P.u.uVP, false, view.VP); gl.uniform3fv(P.u.uEyeH, eyeH); gl.uniform3fv(P.u.uEyeL, eyeL);
gl.uniform1f(P.u.uEyeC, eyeC); gl.uniform1f(P.u.uOccR, occR); gl.uniform1f(P.u.uT, t);
if (P.u.uPx) gl.uniform1f(P.u.uPx, px); if (P.u.uAltX) gl.uniform1f(P.u.uAltX, altX); if (P.u.uSizeK) gl.uniform1f(P.u.uSizeK, sizeK);
if (P.u.uGmst) gl.uniform1f(P.u.uGmst, sun.gmst); if (P.u.uView) gl.uniform2f(P.u.uView, view.w, view.h); if (P.u.uSun) gl.uniform3fv(P.u.uSun, sunDir);
if (P.u.uMinSize) gl.uniform1f(P.u.uMinSize, 1.3); if (P.u.uGlyphPx) gl.uniform1f(P.u.uGlyphPx, range > 0.55 ? 9999 : 6 * px); if (P.u.uAlphaK) gl.uniform1f(P.u.uAlphaK, alphaK); if (P.u.uPass) gl.uniform1f(P.u.uPass, 0);
};
const lineK = opts.lineK ?? 1;
const LP = PR.line; common(LP, lineK); gl.uniform1f(LP.u.uDashK, 1 / Math.max(range * 0.012, 2e-6));
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99) && !(id === 'ais' && range > 1.0)) for (const s of W.static[id]) if (s.kind === 'line') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); }
if (W.dynLineVao && W.dynLineCount) { gl.bindVertexArray(W.dynLineVao); gl.drawArrays(gl.LINES, 0, W.dynLineCount); }
if (opts.extraLines) for (const s of opts.extraLines) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); }
// trails
const kindMask = [W.on('flights') ? 1 : 0, W.on('military') ? 1 : 0, 0, W.on('ga') ? 1 : 0];
const trailLen = range < 0.02 ? 240 : range < 0.2 ? 900 : 1500;
if ((kindMask[0] || kindMask[1] || kindMask[3]) && opts.trails !== false) {
const TP = PR.trail; common(TP, (range > 1.4 ? 0.22 : 0.4) * lineK); gl.uniform4fv(TP.u.uKindMask, kindMask); gl.uniform1f(TP.u.uTrail, 1); gl.uniform1f(TP.u.uTrailLen, trailLen); gl.uniform1f(TP.u.uSelId, W.selFlight ?? -1);
gl.uniform4f(TP.u.uKindSize, 9, 10, 10, 7);
gl.bindVertexArray(W.trailVao); gl.drawArraysInstanced(gl.LINE_STRIP, 0, W.trailK, W.nFlights);
}
// static sprite sets
const SP = PR.sprite; common(SP);
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'add') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
if (W.dynVao && W.dynCount) { gl.bindVertexArray(W.dynVao); gl.drawArrays(gl.POINTS, 0, W.dynCount); }
if (opts.extraSprites) for (const s of opts.extraSprites) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
// flights
if (kindMask[0] || kindMask[1] || kindMask[3]) {
const FP = PR.flight; common(FP); gl.uniform4fv(FP.u.uKindMask, kindMask); gl.uniform1f(FP.u.uTrail, 0); gl.uniform1f(FP.u.uTrailLen, 0); gl.uniform1f(FP.u.uSelId, W.selFlight ?? -1);
gl.uniform4f(FP.u.uKindSize, 13, 15, 14, 10);
gl.bindVertexArray(W.flightVao); gl.drawArraysInstanced(gl.POINTS, 0, 1, W.nFlights);
}
// satellites
const sm0 = [W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0], sm1 = [W.on('debris') ? 1 : 0, W.on('trains') ? 1 : 0, W.on('crewed') ? 1 : 0, 0];
if (sm0[0] || sm1[0] || sm1[1] || sm1[2]) {
const AP2 = PR.sat; common(AP2, opts.satK ?? 1); gl.uniform4fv(AP2.u.uSatMask0, sm0); gl.uniform4fv(AP2.u.uSatMask1, sm1); gl.uniform1f(AP2.u.uSelId, W.selSat ?? -1);
gl.bindVertexArray(W.satVao); gl.drawArrays(gl.POINTS, 0, W.nSats);
}
// alpha pass (smoke, AQ, radar, GNSS, cone)
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE);
common(SP); gl.uniform1f(SP.u.uPass, 1);
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'alpha') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
if (W.alphaVao && W.alphaCount) { gl.bindVertexArray(W.alphaVao); gl.drawArrays(gl.POINTS, 0, W.alphaCount); }
gl.disable(gl.BLEND); gl.disable(gl.DEPTH_TEST); gl.depthMask(true);
gl.bindVertexArray(null);
// ---- post
W.post(chain, outFb, opts.outW ?? view.w, opts.outH ?? view.h, mode, opts);
};
W.post = function (chain, outFb, outW, outH, mode, opts) {
const gl = GL.gl, PR = GL.progs, lv = chain.lv;
const bloomOn = (opts.bloom ?? W.quality.bloom) && mode !== 1;
if (bloomOn) {
const B = PR.bright; gl.useProgram(B.p);
gl.bindFramebuffer(gl.FRAMEBUFFER, lv[0].a.fb); gl.viewport(0, 0, lv[0].a.w, lv[0].a.h);
GL.bindTex(B, 'uSrc', chain.scene.tex, 0); gl.uniform2f(B.u.uTexel, 0.5 / chain.w, 0.5 / chain.h); gl.uniform1f(B.u.uThr, mode === 4 ? 1.2 : 3.2); GL.fsq(B);
const Dn = PR.down;
for (let i = 1; i < 4; i++) { gl.useProgram(Dn.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].a.fb); gl.viewport(0, 0, lv[i].a.w, lv[i].a.h); GL.bindTex(Dn, 'uSrc', lv[i - 1].a.tex, 0); gl.uniform2f(Dn.u.uTexel, 1 / lv[i - 1].a.w, 1 / lv[i - 1].a.h); GL.fsq(Dn); }
const Up = PR.up;
for (let i = 2; i >= 0; i--) { gl.useProgram(Up.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].b.fb); gl.viewport(0, 0, lv[i].b.w, lv[i].b.h); const src = i === 2 ? lv[3].a : lv[i + 1].b; GL.bindTex(Up, 'uSrc', src.tex, 0); GL.bindTex(Up, 'uBase', lv[i].a.tex, 1); gl.uniform2f(Up.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(Up.u.uMix, 1.0); GL.fsq(Up); }
}
const Cp = PR.comp; gl.useProgram(Cp.p);
gl.bindFramebuffer(gl.FRAMEBUFFER, outFb);
gl.viewport(0, 0, opts.vpW ?? (outFb ? outW : gl.drawingBufferWidth), opts.vpH ?? (outFb ? outH : gl.drawingBufferHeight));
GL.bindTex(Cp, 'uScene', chain.scene.tex, 0); GL.bindTex(Cp, 'uBloom', bloomOn ? lv[0].b.tex : lv[0].a.tex, 1);
gl.uniform2f(Cp.u.uTexel, 1 / chain.w, 1 / chain.h);
gl.uniform1f(Cp.u.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : 0.62));
gl.uniform1f(Cp.u.uBloomK, bloomOn ? (mode === 4 ? 0.3 : 0.16) : 0);
gl.uniform1f(Cp.u.uVig, opts.vig ?? (mode === 1 ? 0.25 : 0.9));
gl.uniform1f(Cp.u.uGrain, (opts.grain ?? W.quality.grain) && mode !== 1 ? 0.022 : 0);
gl.uniform1f(Cp.u.uT, performance.now() / 1000 % 100);
gl.uniform1f(Cp.u.uFade, opts.fade ?? 1);
gl.uniform1f(Cp.u.uFxaa, 1);
gl.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.08);
gl.uniform1i(Cp.u.uMode, mode);
gl.uniform3fv(Cp.u.uLift, [0.00017, 0.00038, 0.001]);
gl.uniform3fv(Cp.u.uGain, mode === 0 ? [1.02, 1.0, 0.97] : [1, 1, 1]);
GL.fsq(Cp);
};
/* ---------- active local scene (city / terrain) ---------- */
W.activeScene = function (view) {
if (!GE.scenes) return null;
const range = view.range ?? 1; if (range > 0.03) return null;
const [la, lo] = view.tLat != null ? [view.tLat, view.tLon] : geo.ll(view.eye);
let best = null, bd = 1e9;
for (const s of GE.scenes.list) { const d = geo.dist(la, lo, s.lat, s.lon) * EARTH_KM; if (d < s.radiusKm && d < bd) { bd = d; best = s; } }
if (best) GE.scenes.ensure(best);
return best;
};
/* ---------- picking (CPU) ---------- */
W.pick = function (view, x, y, radius = 26) {
const cands = [];
const consider = (p, item) => { const s = W.project(view, p); if (!s || !s.vis) return; const d = Math.hypot(s.x - x, s.y - y); if (d < radius) cands.push({ d: d - (item.prio || 0), ...item }); };
const t = W.t;
for (const p of W.situationPins) consider(geo.vec(p.lat, p.lon, 0.2), { kind: 'situation', id: p.id, title: p.title, prio: 10, lat: p.lat, lon: p.lon });
if (W.on('flights') || W.on('military') || W.on('ga')) GE.FLIGHTS.forEach((f, i) => {
if ((f.kind === 0 && !W.on('flights')) || (f.kind === 1 && !W.on('military')) || (f.kind === 3 && !W.on('ga'))) return;
const p = flightAt(f, t); if (!p) return;
consider(v3.scale(p.v, 1 + (p.altKm * W.altX) / EARTH_KM), { kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog }, prio: 2 });
});
if (W.on('helis')) HELI_CIRCUITS.forEach((hc, i) => { const p = heliAt(hc, t); consider(p.v, { kind: 'heli', id: i, title: hc.cs, subtitle: `${hc.type} · ${hc.field || 'orbit'} · ${Math.round(p.altKm * 3281)} ft`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, prio: 4 }); });
if (W.on('ais') && W._shipCache) GE.SHIPS.forEach((s, i) => { const p = W._shipCache[i]; if (!p) return; consider(geo.vec(p.lat, p.lon, 0.03), { kind: 'ship', id: i, title: s.name, subtitle: `${s.kind} · ${p.sog ? p.sog.toFixed(1) + ' kn' : 'at anchor'} · MMSI ${s.mmsi}`, lat: p.lat, lon: p.lon, hdg: p.hdg }); });
if (W.on('crewed')) [GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo] = geo.ll(p); consider(p, { kind: 'sat', id: GE.SATS.indexOf(s), title: s.name, subtitle: `${Math.round(s.alt)} km · ${(Math.sqrt(398600.4418 / (6371 + s.alt))).toFixed(2)} km/s`, lat: la, lon: lo, prio: 6 }); });
if (W.on('quakes')) GE.QUAKES.forEach((q, i) => { if (q.t > t) return; consider(geo.vec(q.lat, q.lon, 0.5), { kind: 'quake', id: i, title: `M ${q.mag.toFixed(1)}`, subtitle: `${q.depth} km deep · ${fmt.ago(-q.t * 1000 + W.offsetH * 3600000)}`, lat: q.lat, lon: q.lon, prio: q.mag }); });
if (W.on('holo') || W.on('signed')) HOLOS.forEach((hh, i) => consider(geo.vec(hh.lat, hh.lon, 0.2), { kind: 'holo', id: i, title: hh.title, subtitle: `Signed capture · @${hh.by} · illustrative`, lat: hh.lat, lon: hh.lon, prio: 5 }));
cands.sort((a, b) => a.d - b.d);
return cands[0] || null;
};
W.contactsNear = function (lat, lon, km) {
const out = [], t = W.t;
GE.FLIGHTS.forEach((f, i) => { const p = flightAt(f, t); if (!p) return; const d = geo.dist(lat, lon, p.lat, p.lon) * EARTH_KM; if (d < km) out.push({ kind: 'flight', id: i, title: f.cs, sub: f.type, lat: p.lat, lon: p.lon, altM: p.altKm * 1000, headingDeg: p.hdg * R2D, d }); });
[GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo, al] = geo.ll(p); const d = geo.dist(lat, lon, la, lo) * EARTH_KM; if (d < 2200) out.push({ kind: 'sat', title: s.name, sub: Math.round(al) + ' km', lat: la, lon: lo, altM: al * 1000, d }); });
return out.sort((a, b) => a.d - b.d);
};
W.auroraK = 0.85;
Object.assign(W, { heliAt, holdAt, droneAt, runnerAt, HOLDERS, RUN_V });
+240
View File
@@ -0,0 +1,240 @@
/* Local 3D scenes for the low-altitude "photoreal city" treatment: procedural extruded city blocks
aligned with the globe shader's street grid, stadium bowl, airfields, launch pad, and a Himalayan
valley heightfield for the Bhote Koshi reconstruction (river carved along the real corridor). */
const SC = (GE.scenes = {});
const VSTR = 12; // pos3 nor3 uv2 attr4
SC.list = [
{ id: 'nyc', name: 'Midtown Manhattan', lat: 40.7549, lon: -73.9840, block: 88, rot: 29 * D2R, urban: 0.55, radiusKm: 16, kind: 'city', R: 3000, maxH: 240, tower: { lat: 40.7553, lon: -73.9838, h: 412, w: 46 } },
{ id: 'chi', name: 'Chicago Loop', lat: 41.8789, lon: -87.6359, block: 100, rot: 0, urban: 0.45, radiusKm: 18, kind: 'city', R: 2600, maxH: 260, water: [0.94, 0.342, 1824, 1], downtown: [41.8807, -87.6293] },
{ id: 'wembley', name: 'Wembley', lat: 51.5560, lon: -0.2796, block: 74, rot: 0.21, urban: 0.25, radiusKm: 10, kind: 'stadium', R: 1500, maxH: 40 },
{ id: 'novosel', name: 'Cairns Army Airfield', lat: 31.2760, lon: -85.7130, block: 140, rot: 0.06, urban: -0.2, radiusKm: 28, kind: 'airfield', R: 0 },
{ id: 'koshi', name: 'Bhote Koshi corridor', lat: 28.2150, lon: 85.3450, block: 100, rot: 0, urban: -1, radiusKm: 32, kind: 'terrain', R: 0 },
{ id: 'cape', name: 'SLC-40', lat: 28.5618, lon: -80.5772, block: 120, rot: 0, urban: -0.5, radiusKm: 14, kind: 'pad', R: 0 },
{ id: 'drone', name: 'Drone corridor', lat: 30.5880, lon: -96.2900, block: 62, rot: 0.12, urban: 0.3, radiusKm: 12, kind: 'suburb', R: 2200, maxH: 9 },
{ id: 'atl', name: 'ATL', lat: 33.6407, lon: -84.4277, block: 120, rot: 0, urban: 0.0, radiusKm: 10, kind: 'airport', R: 0 },
];
SC.byId = Object.fromEntries(SC.list.map((s) => [s.id, s]));
// Bhote Koshi corridor (approximate public geography): Rasuwagadhi → Timure → Syaphrubesi → Trishuli
const KOSHI = [[28.2860, 85.3815], [28.2802, 85.3797], [28.2700, 85.3752], [28.2555, 85.3705], [28.2400, 85.3650], [28.2250, 85.3600], [28.2100, 85.3550], [28.1950, 85.3500], [28.1800, 85.3460], [28.1660, 85.3420], [28.1600, 85.3380], [28.1500, 85.3230], [28.1400, 85.3060], [28.1300, 85.2900]];
SC.KOSHI = KOSHI;
SC.FLOOD = { startMs: Date.UTC(2026, 7, 26, 7, 45), speedMH: 18000, crest: 9 };
function Mesh() { this.v = []; this.i = []; }
Mesh.prototype.vert = function (p, n, uv, at) { this.v.push(p[0], p[1], p[2], n[0], n[1], n[2], uv[0], uv[1], at[0], at[1], at[2], at[3]); return this.v.length / VSTR - 1; };
Mesh.prototype.quad = function (a, b, c, d, n, uvs, at) { const i0 = this.vert(a, n, uvs[0], at), i1 = this.vert(b, n, uvs[1], at), i2 = this.vert(c, n, uvs[2], at), i3 = this.vert(d, n, uvs[3], at); this.i.push(i0, i1, i2, i0, i2, i3); };
// oriented box: center (x,y) in ENU m, size w×d, height h from z0, rotation rot
Mesh.prototype.box = function (x, y, w, d, h, rot, at, z0 = 0) {
if (at[0] === 4) at = [4, at[1], at[2], d];
const c = Math.cos(rot), s = Math.sin(rot);
const P = (u, v) => [x + u * c - v * s, y + u * s + v * c];
const pts = [P(-w / 2, -d / 2), P(w / 2, -d / 2), P(w / 2, d / 2), P(-w / 2, d / 2)];
const z1 = z0 + h;
for (let k = 0; k < 4; k++) {
const a = pts[k], b = pts[(k + 1) % 4];
const ex = b[0] - a[0], ey = b[1] - a[1], L = Math.hypot(ex, ey);
const n = [ey / L, -ex / L, 0];
this.quad([a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1], n, [[0, 0], [L, 0], [L, h], [0, h]], at);
}
this.quad([pts[0][0], pts[0][1], z1], [pts[1][0], pts[1][1], z1], [pts[2][0], pts[2][1], z1], [pts[3][0], pts[3][1], z1], [0, 0, 1], [[0, 0], [w, 0], [w, d], [0, d]], at);
};
const enuOf = (sc, lat, lon) => { const e = geo.enu(sc.lat, sc.lon); const d = v3.sub(geo.vec(lat, lon), geo.vec(sc.lat, sc.lon)); return [v3.dot(d, e.east) * EARTH_M, v3.dot(d, e.north) * EARTH_M]; };
SC.enuOf = enuOf;
const llOf = (sc, x, y) => geo.offset(sc.lat, sc.lon, x, y);
function genCity(sc, M, R0) {
const B = sc.block, c = Math.cos(sc.rot), s = Math.sin(sc.rot);
const rr = rng(sc.id.length * 991 + 7);
const R = sc.R, n = Math.ceil(R / B) + 1;
const dt = sc.downtown ? enuOf(sc, sc.downtown[0], sc.downtown[1]) : [0, 0];
const twr = sc.tower ? enuOf(sc, sc.tower.lat, sc.tower.lon) : null;
const hsh = (i, j) => hash01(i * 157.31 + j * 311.7 + sc.id.length);
for (let i = -n; i < n; i++) for (let j = -n; j < n; j++) {
const pcx = (i + 0.5) * B, pcy = (j + 0.5) * B;
const qx = c * pcx - s * pcy, qy = s * pcx + c * pcy;
const rd = Math.hypot(qx, qy); if (rd > R) continue;
if (sc.water && qx * sc.water[0] + qy * sc.water[1] > sc.water[2] - 40) continue;
if (sc.kind === 'stadium' && Math.hypot(qx, qy) < 280) continue;
if (twr && Math.hypot(qx - twr[0], qy - twr[1]) < 40) continue;
const [la, lo] = llOf(sc, qx, qy);
if (GE.world.waterAt(la, lo) > 0.6 && !sc.water) continue;
const dens = clamp(GE.world.lightsAt(la, lo) * 0.9 + sc.urban, 0, 1) * smooth(R, R * 0.6, rd);
if (hsh(i, j) > dens * 1.25 + 0.05) continue;
const rw = (0.085 + ((((i % 5) + 5) % 5) === 4 ? 0.05 : 0)) * B + 3;
const inner = B - 2 * rw;
const kx = inner > 50 && rr() < 0.6 ? 2 : 1, ky = inner > 50 && rr() < 0.6 ? 2 : 1;
const ddt = Math.hypot(qx - dt[0], qy - dt[1]);
for (let a = 0; a < kx; a++) for (let b = 0; b < ky; b++) {
const lw = inner / kx, ld = inner / ky;
const lpx = i * B + rw + lw * (a + 0.5), lpy = j * B + rw + ld * (b + 0.5);
const x = c * lpx - s * lpy, y = s * lpx + c * lpy;
const hh = hash01(lpx * 0.37 + lpy * 0.71);
const core = Math.exp(-ddt / (sc.R * 0.28));
let h = sc.kind === 'suburb' ? 5 + hh * 4 : 9 + Math.pow(dens, 1.6) * (sc.maxH * (0.15 + 0.85 * core)) * Math.exp(rr.gauss() * 0.45);
h = clamp(h, 6, sc.maxH * 1.4);
const inset = 2 + hh * 3;
const type = sc.kind === 'suburb' ? 6 : 0;
M.box(x, y, lw - inset * 2, ld - inset * 2, h, sc.rot, [type, hh, h, 0]);
if (type === 0 && h > 60 && hh > 0.5) M.box(x, y, (lw - inset * 2) * 0.6, (ld - inset * 2) * 0.6, h * 0.25, sc.rot, [0, hh, h * 1.25, 0], h);
}
}
if (twr) { // the fictional tower (skyscraper climb reconstruction)
const T = sc.tower; M.box(twr[0], twr[1], T.w, T.w, T.h * 0.62, sc.rot, [0, 0.8, T.h, 0]); M.box(twr[0], twr[1], T.w * 0.72, T.w * 0.72, T.h * 0.3, sc.rot, [0, 0.8, T.h, 0], T.h * 0.62); M.box(twr[0], twr[1], 3, 3, T.h * 0.08, sc.rot, [5, 0.5, T.h, 0], T.h * 0.92);
}
}
function genStadium(sc, M) {
const N = 72; const rings = [[64, 46, 4], [92, 74, 20], [128, 108, 44]];
for (let k = 0; k < N; k++) {
const a0 = (k / N) * TAU, a1 = ((k + 1) / N) * TAU;
for (let r = 0; r < rings.length - 1; r++) {
const [ax, ay, az] = rings[r], [bx, by, bz] = rings[r + 1];
const p = (A, ex, ey, ez) => [Math.cos(A) * ex, Math.sin(A) * ey, ez];
const p0 = p(a0, ax, ay, az), p1 = p(a1, ax, ay, az), p2 = p(a1, bx, by, bz), p3 = p(a0, bx, by, bz);
const e1 = v3.sub(p1, p0), e2 = v3.sub(p3, p0); const n = v3.norm(v3.cross(e1, e2));
M.quad(p0, p1, p2, p3, n[2] < 0 ? v3.scale(n, -1) : n, [[k * 4, r * 20], [k * 4 + 4, r * 20], [k * 4 + 4, r * 20 + 20], [k * 4, r * 20 + 20]], [3, 0.5, 40, 0]);
}
// outer facade
const [ox, oy] = [134, 114]; const o0 = [Math.cos(a0) * ox, Math.sin(a0) * oy], o1 = [Math.cos(a1) * ox, Math.sin(a1) * oy];
const L = Math.hypot(o1[0] - o0[0], o1[1] - o0[1]); const nn = v3.norm([o0[0] + o1[0], o0[1] + o1[1], 0]);
M.quad([o0[0], o0[1], 0], [o1[0], o1[1], 0], [o1[0], o1[1], 50], [o0[0], o0[1], 50], nn, [[0, 0], [L, 0], [L, 50], [0, 50]], [0, 0.75, 50, 0]);
// roof ring
const r0 = [Math.cos(a0) * 104, Math.sin(a0) * 86], r1 = [Math.cos(a1) * 104, Math.sin(a1) * 86];
M.quad([r0[0], r0[1], 52], [r1[0], r1[1], 52], [o1[0] * 1.02, o1[1] * 1.02, 52], [o0[0] * 1.02, o0[1] * 1.02, 52], [0, 0, 1], [[0, 0], [1, 0], [1, 1], [0, 1]], [0, 0.6, 52, 0]);
}
M.quad([-55, -36, 0.6], [55, -36, 0.6], [55, 36, 0.6], [-55, 36, 0.6], [0, 0, 1], [[0, 0], [110, 0], [110, 72], [0, 72]], [4, 1, 0, 0]);
M.box(-40, 0, 14, 30, 13, 0, [5, 0.5, 13, 0], 0.6);
for (let k = 0; k < 6; k++) M.box(-40, -12 + k * 5, 1.2, 1.2, 22, 0, [5, 0.5, 22, 0], 13);
}
function genAirfield(sc, M) {
const rw = (x, y, L, W, rot) => M.box(x, y, L, W, 0.4, rot, [4, 0, 0.4, 0]);
rw(0, 0, 1550, 46, 0.06); rw(150, 100, 1500, 46, 0.06 + 1.1); rw(-300, -420, 900, 30, 0.06 + Math.PI / 2);
for (let i = 0; i < 26; i++) { const x = -600 + (i % 13) * 95, y = 260 + Math.floor(i / 13) * 70; M.box(x, y, 28, 28, 0.35, 0.06, [4, 0, 0.3, 0]); }
for (let i = 0; i < 6; i++) M.box(-500 + i * 120, 420, 80, 48, 14, 0.06, [0, 0.4 + i * 0.05, 14, 0]);
M.box(260, -180, 14, 14, 26, 0.06, [0, 0.9, 26, 0]);
// stagefields
for (const [la, lo] of [[31.36, -85.63], [31.20, -85.81], [31.42, -85.79]]) { const [x, y] = enuOf(sc, la, lo); M.box(x, y, 700, 40, 0.4, 0.4, [4, 0, 0.4, 0]); for (let k = 0; k < 6; k++) M.box(x - 250 + k * 100, y + 80, 26, 26, 0.35, 0.4, [4, 0, 0.3, 0]); }
}
function genPad(sc, M) {
M.box(0, 0, 220, 160, 0.5, 0, [4, 0, 0.5, 0]);
M.box(-14, 0, 12, 12, 88, 0, [0, 0.55, 88, 0]);
M.box(4, 0, 4.2, 4.2, 70, 0, [0, 0.98, 70, 0]);
M.box(4, 0, 4.4, 4.4, 2, 0, [5, 0.5, 2, 0], 70);
for (const [x, y, w, d, h] of [[300, 180, 60, 40, 22], [-260, 220, 90, 30, 12], [400, -260, 30, 30, 14]]) M.box(x, y, w, d, h, 0, [0, 0.4, h, 0]);
}
function genAirport(sc, M) {
const rw = [[-1000, 1200, 3700], [-1100, 950, 2700], [-900, -300, 2700], [-1000, -560, 3000], [-1150, -1200, 2900]];
for (const [x, y, L] of rw) M.box(x + L / 2 - 1000, y, L, 46, 0.4, 0, [4, 0, 0.4, 0]);
for (let k = 0; k < 7; k++) M.box(-1100 + k * 420, 330, 46, 620, 16, 0, [0, 0.35, 16, 0]);
M.box(-1500, 330, 260, 220, 22, 0, [0, 0.6, 22, 0]);
M.box(1400, 1400, 14, 14, 70, 0, [0, 0.9, 70, 0]);
}
// Bhote Koshi valley heightfield
function koshiRiverXY(sc) { return KOSHI.map(([la, lo]) => enuOf(sc, la, lo)); }
function genTerrain(sc, M) {
const riv = koshiRiverXY(sc);
const cum = [0]; for (let i = 1; i < riv.length; i++) cum.push(cum[i - 1] + Math.hypot(riv[i][0] - riv[i - 1][0], riv[i][1] - riv[i - 1][1]));
sc.riverLen = cum[cum.length - 1];
const nearest = (x, y) => { let bd = 1e12, bs = 0; for (let i = 1; i < riv.length; i++) { const [ax, ay] = riv[i - 1], [bx, by] = riv[i]; const ex = bx - ax, ey = by - ay; const L2 = ex * ex + ey * ey; const t = clamp(((x - ax) * ex + (y - ay) * ey) / L2, 0, 1); const px = ax + ex * t - x, py = ay + ey * t - y; const d = px * px + py * py; if (d < bd) { bd = d; bs = cum[i - 1] + t * Math.sqrt(L2); } } return [Math.sqrt(bd), bs]; };
const bed = (s) => 1880 - s * 0.021;
const ridged = (x, y) => { let a = 0.5, f = 1 / 5200, sum = 0; for (let o = 0; o < 6; o++) { const n = 1 - Math.abs(vn2(x * f + o * 13.1, y * f + o * 7.7) * 2 - 1); sum += n * n * a; a *= 0.5; f *= 2.05; } return sum; };
const height = (x, y) => {
const [d, s] = nearest(x, y);
const base = 1750 + ridged(x, y) * 2900 + y * 0.03 + Math.max(0, x) * 0.035;
const b = bed(s);
const k = Math.pow(clamp(d / 2600, 0, 1), 0.72);
return [b + (base - b) * k + Math.min(d, 60) * 0.05, d, s];
};
sc.height = (x, y) => height(x, y)[0];
const N = 180, HALF = 19000; const step = (2 * HALF) / N;
const H = new Float32Array((N + 1) * (N + 1)), Dd = new Float32Array((N + 1) * (N + 1)), Ss = new Float32Array((N + 1) * (N + 1));
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) { const x = -HALF + i * step, y = -HALF + j * step; const [h, d, s] = height(x, y); const k = j * (N + 1) + i; H[k] = h; Dd[k] = d; Ss[k] = s; }
const base = M.v.length / VSTR;
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {
const k = j * (N + 1) + i; const x = -HALF + i * step, y = -HALF + j * step;
const hl = H[j * (N + 1) + Math.max(0, i - 1)], hr = H[j * (N + 1) + Math.min(N, i + 1)], hd = H[Math.max(0, j - 1) * (N + 1) + i], hu = H[Math.min(N, j + 1) * (N + 1) + i];
const n = v3.norm([(hl - hr) / (2 * step), (hd - hu) / (2 * step), 1]);
const edge = Math.min(i, j, N - i, N - j); const z = edge === 0 ? 0 : H[k];
M.vert([x, y, z], n, [Ss[k], 0], [1, hash01(k), H[k], smooth(260, 40, Dd[k])]);
}
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const a = base + j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1; M.i.push(a, b, d, a, d, c); }
// river / flood water strip
const wbase = M.v.length / VSTR; const pts = [];
for (let s = 0; s <= sc.riverLen; s += 70) { let i = 1; while (i < cum.length - 1 && cum[i] < s) i++; const f = (s - cum[i - 1]) / (cum[i] - cum[i - 1]); const x = lerp(riv[i - 1][0], riv[i][0], f), y = lerp(riv[i - 1][1], riv[i][1], f); const tx = riv[i][0] - riv[i - 1][0], ty = riv[i][1] - riv[i - 1][1], tl = Math.hypot(tx, ty); pts.push([x, y, -ty / tl, tx / tl, s]); }
for (const [x, y, nx, ny, s] of pts) { const W2 = 210; const z = bed(s) + 4; M.vert([x - nx * W2, y - ny * W2, z], [0, 0, 1], [s, -1], [2, 0, 0, 0]); M.vert([x + nx * W2, y + ny * W2, z], [0, 0, 1], [s, 1], [2, 0, 0, 0]); }
for (let k = 0; k < pts.length - 1; k++) { const a = wbase + k * 2; M.i.push(a, a + 1, a + 3, a, a + 3, a + 2); }
// villages & bridge
const village = (la, lo, n, spread) => { const [cx, cy] = enuOf(sc, la, lo); const r = rng(Math.round(la * 1000)); for (let k = 0; k < n; k++) { const x = cx + r.gauss() * spread, y = cy + r.gauss() * spread; const z = sc.height(x, y); M.box(x, y, 7 + r() * 6, 8 + r() * 6, 5 + r() * 4, r() * 3, [6, r(), 6, 0], z - 1); } };
village(28.1600, 85.3395, 70, 130); village(28.2560, 85.3690, 22, 80); village(28.2790, 85.3770, 14, 60); village(28.2050, 85.3520, 10, 60);
const [bx, by] = enuOf(sc, 28.2802, 85.3797); M.box(bx, by, 6, 120, 2.5, 0.6, [0, 0.9, 3, 0], bed(sc.riverLen * 0.03) + 18);
const [bx2, by2] = enuOf(sc, 28.1625, 85.3405); M.box(bx2, by2, 5, 110, 2.5, 1.0, [0, 0.9, 3, 0], bed(sc.riverLen * 0.83) + 16);
sc.riverPts = pts; sc.bed = bed;
}
function vn2(x, y) { const i = Math.floor(x), j = Math.floor(y), fx = x - i, fy = y - j; const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); const h = (a, b) => hash01(a * 12.9898 + b * 78.233); return lerp(lerp(h(i, j), h(i + 1, j), u), lerp(h(i, j + 1), h(i + 1, j + 1), u), v); }
SC.ensure = function (sc) {
if (sc.mesh || sc.building) return;
sc.building = true;
const M = new Mesh();
if (sc.kind === 'city' || sc.kind === 'suburb' || sc.kind === 'stadium') genCity(sc, M);
if (sc.kind === 'stadium') genStadium(sc, M);
if (sc.kind === 'airfield') genAirfield(sc, M);
if (sc.kind === 'pad') genPad(sc, M);
if (sc.kind === 'airport') genAirport(sc, M);
if (sc.kind === 'terrain') genTerrain(sc, M);
if (sc.kind === 'suburb') M.box(0, 0, 130, 60, 12, sc.rot, [0, 0.3, 12, 0]);
const gl = GL.gl, P = GL.progs.mesh;
const vb = GL.buffer(new Float32Array(M.v));
const ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(M.i), gl.STATIC_DRAW);
sc.vao = GL.vao(P, { aPos: [vb, 3, VSTR, 0], aNor: [vb, 3, VSTR, 3], aUV: [vb, 2, VSTR, 6], aAttr: [vb, 4, VSTR, 8] }, ib);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
sc.count = M.i.length; sc.mesh = true; sc.building = false;
sc.enu = geo.enu(sc.lat, sc.lon); sc.origin = geo.vec(sc.lat, sc.lon);
};
/* ---------- JS atmosphere (matches the shader) for mesh lighting & fog ---------- */
const HR = 0.0021, HM = 0.0006, RA = 1.0165, BR = [22.1, 51.5, 126.3], BMk = 42.0;
function odH(r, mu, H) { const c = Math.sqrt(Math.PI * 0.5 * r / H), h = (r - 1) / H; if (mu >= 0) return H * Math.exp(-h) * c / (c * mu + 1); const r0 = r * Math.sqrt(Math.max(0, 1 - mu * mu)); if (r0 < 1) return 1e4; const h0 = (r0 - 1) / H, c0 = Math.sqrt(Math.PI * 0.5 * r0 / H); return H * (2 * Math.exp(-h0) * c0 - Math.exp(-h) * c / (-c * mu + 1)); }
function sunTransJS(x, sun) { const r = v3.len(x), mu = v3.dot(x, sun) / r; const oR = odH(r, mu, HR), oM = odH(r, mu, HM); return BR.map((b) => Math.exp(-(b * oR + BMk * 1.11 * oM))); }
function atmoJS(ro, rd, tEnd, sun, steps = 10) {
const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - RA * RA, d = b * b - c; if (d < 0) return [0, 0, 0];
const sq = Math.sqrt(d); const t0 = Math.max(-b - sq, 0), t1 = Math.min(-b + sq, tEnd); if (t1 <= t0) return [0, 0, 0];
const mu = v3.dot(rd, sun), pR = (3 / (16 * Math.PI)) * (1 + mu * mu), g = 0.76, pM = (3 / (8 * Math.PI)) * ((1 - g * g) * (1 + mu * mu)) / ((2 + g * g) * Math.pow(1 + g * g - 2 * g * mu, 1.5));
let sR = [0, 0, 0], sM = [0, 0, 0], oR = 0, oM = 0; const L = t1 - t0;
for (let i = 0; i < steps; i++) { const fa = i / steps, fb = (i + 1) / steps; const ta = fa * fa, tb = fb * fb; const ds = (tb - ta) * L; const t = t0 + (ta + tb) * 0.5 * L; const x = v3.madd(ro, rd, t); const r = v3.len(x), h = r - 1; const dR = Math.exp(-h / HR), dM = Math.exp(-h / HM); oR += dR * ds; oM += dM * ds; const muS = v3.dot(x, sun) / r; const lR = odH(r, muS, HR), lM = odH(r, muS, HM); for (let k = 0; k < 3; k++) { const att = Math.exp(-(BR[k] * (oR + lR) + BMk * 1.11 * (oM + lM))); sR[k] += dR * att * ds; sM[k] += dM * att * ds; } }
return [0, 1, 2].map((k) => 20 * (sR[k] * BR[k] * pR + sM[k] * BMk * pM));
}
SC.atmoJS = atmoJS; SC.sunTransJS = sunTransJS;
SC.draw = function (sc, view, { sun, t, eye, opts }) {
const gl = GL.gl, P = GL.progs.mesh; if (!sc.vao) return;
const e = sc.enu, o = sc.origin, R = EARTH_M;
const d = v3.sub(o, eye);
const M = [e.east[0] / R, e.east[1] / R, e.east[2] / R, 0, e.north[0] / R, e.north[1] / R, e.north[2] / R, 0, e.up[0] / R, e.up[1] / R, e.up[2] / R, 0, d[0], d[1], d[2], 1];
const MVP = m4.mul(view.VP, M);
gl.useProgram(P.p);
gl.uniformMatrix4fv(P.u.uMVP, false, MVP);
const sd = sun.dir; const sunL = [v3.dot(sd, e.east), v3.dot(sd, e.north), v3.dot(sd, e.up)];
gl.uniform3fv(P.u.uSunL, sunL);
const tr = sunTransJS(v3.scale(o, 1.0002), sd);
const k = 20 / Math.PI; const night = opts.nightForce ? 1 : 1 - smooth(-0.14, 0.06, sunL[2]);
const clear = (opts.lens || GE.world.lens) === 'clear';
gl.uniform3fv(P.u.uSunCol, clear ? [2.2, 2.2, 2.2] : tr.map((x) => x * k * (opts.nightForce ? 0.03 : 1)));
const amb = smooth(-0.25, 0.5, sunL[2]);
gl.uniform3fv(P.u.uSkyCol, clear ? [1.2, 1.25, 1.35] : [0.03 * 20 / Math.PI * 1.6 * amb + 0.014, 0.045 * 20 / Math.PI * 1.6 * amb + 0.02, 0.085 * 20 / Math.PI * 1.6 * amb + 0.04]);
// fog colour: inscatter along the horizon in the camera's look direction
const fwd = v3.scale(view.back, -1); const fh = v3.norm(v3.madd(fwd, e.up, -v3.dot(fwd, e.up))); const ro = v3.madd(o, e.up, 0.0003);
let fog = atmoJS(ro, fh, 0.012, sd, 8); fog = fog.map((x) => x * 0.8 + 0.0015);
gl.uniform3fv(P.u.uFogCol, fog);
const el = [v3.dot(v3.scale(d, -1), e.east) * R, v3.dot(v3.scale(d, -1), e.north) * R, v3.dot(v3.scale(d, -1), e.up) * R];
gl.uniform3fv(P.u.uEyeL, el);
gl.uniform1f(P.u.uNight, night); gl.uniform1f(P.u.uT, t);
gl.uniform1f(P.u.uVis, sc.kind === 'terrain' ? 110000 : 26000);
const fl = SC.FLOOD; const simMs = opts.simMs ?? GE.world.simMs;
gl.uniform4f(P.u.uFlood, (simMs - fl.startMs) / 3600000, 0, fl.speedMH, fl.crest);
gl.uniform1f(P.u.uClear, clear ? 1 : 0);
gl.bindVertexArray(sc.vao);
gl.drawElements(gl.TRIANGLES, sc.count, gl.UNSIGNED_INT, 0);
gl.bindVertexArray(null);
};
// ground height (m) at lat/lon for scenes with terrain
SC.groundAt = function (lat, lon) { const sc = SC.byId.koshi; if (!sc.height) return 0; const [x, y] = enuOf(sc, lat, lon); return sc.height(x, y); };
+189
View File
@@ -0,0 +1,189 @@
/* Camera: orbit (target lat/lon, range, heading, tilt, lens shift) with touch/pinch/wheel and
inertia; fly-to along eased great-circle arcs with a log-space altitude hump (van Wijk-style);
free rigs (ride / look / narrative) with smooth hand-off. */
const Cam = (GE.cam = {
lat: 18, lon: 40, range: 2.7, heading: 0, tilt: 0, shiftX: 0, shiftY: 0, fovMin: 40 * D2R,
anim: null, vel: null, rig: null, rigBlend: 0, rigFrom: null, minRange: 0.00007, maxRange: 6,
interacting: false, lastInput: 0, enabled: true,
});
Cam.maxTilt = (r) => lerp(0.0, 1.32, smooth(1.4, 0.004, r));
Cam.basis = function () {
const T = geo.vec(Cam.lat, Cam.lon, Cam.targetAltKm || 0);
const { east, north, up } = geo.enu(Cam.lat, Cam.lon);
const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading);
const F = v3.add(v3.scale(north, ch), v3.scale(east, sh));
const tilt = Math.min(Cam.tilt, Cam.maxTilt(Cam.range));
const off = v3.norm(v3.add(v3.scale(up, Math.cos(tilt)), v3.scale(F, -Math.sin(tilt))));
const eye = v3.madd(T, off, Cam.range);
const upv = v3.norm(v3.add(v3.scale(F, Math.cos(tilt)), v3.scale(up, Math.sin(tilt))));
const back = off; const right = v3.norm(v3.cross(upv, back));
return { eye, right, up: v3.cross(back, right), back, T };
};
function fovFor(w, h, fovMin) { const aspect = w / h; return aspect < 1 ? 2 * Math.atan(Math.tan(fovMin / 2) / aspect) : fovMin; }
Cam.view = function (w, h) {
let b = Cam.basis(); let fovMin = Cam.fovMin; let range = Cam.range;
if (Cam.rig) {
const r = Cam.rig.pose();
const k = ease.inOut(clamp(Cam.rigBlend, 0, 1));
const from = Cam.rigFrom || b;
const eye = v3.lerp(from.eye, r.eye, k);
const back = v3.norm(v3.lerp(from.back, r.back, k)); let up = v3.norm(v3.lerp(from.up, r.up, k));
const right = v3.norm(v3.cross(up, back)); up = v3.cross(back, right);
b = { eye, right, up, back };
fovMin = lerp(Cam.fovMin, r.fov || Cam.fovMin, k);
range = lerp(Cam.range, v3.len(eye) - 1, k);
}
const aspect = w / h, fovY = fovFor(w, h, fovMin);
const alt = Math.max(v3.len(b.eye) - 1, 1e-7);
const horizon = Math.sqrt(2 * alt + alt * alt);
const near = clamp(alt * 0.03, 1.2e-8, 0.3);
const far = Math.max(near * 20, Math.min(12, horizon * 1.3 + 0.004));
const [tLat, tLon] = Cam.rig ? geo.ll(b.eye) : [Cam.lat, Cam.lon];
return { eye: b.eye, right: b.right, up: b.up, back: b.back, fovY, aspect, near, far, shiftX: Cam.shiftX, shiftY: Cam.shiftY, w, h, range, tLat, tLon };
};
Cam.ray = function (view, x, y) {
const nx = (x / view.w) * 2 - 1, ny = 1 - (y / view.h) * 2;
const tY = Math.tan(view.fovY / 2), tX = tY * view.aspect;
return v3.norm(v3.add(v3.add(v3.scale(view.right, (nx - view.shiftX) * tX), v3.scale(view.up, (ny - view.shiftY) * tY)), v3.scale(view.back, -1)));
};
Cam.groundAt = function (view, x, y) {
const rd = Cam.ray(view, x, y), ro = view.eye; const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - 1, d = b * b - c;
if (d < 0 || b > 0) return null; const t = c / (-b + Math.sqrt(d)); return geo.ll(v3.madd(ro, rd, t));
};
Cam.snapshot = () => ({ lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftY: Cam.shiftY, shiftX: Cam.shiftX });
Cam.set = (s) => { Object.assign(Cam, s); Cam.anim = null; Cam.vel = null; };
/* ---------- fly-to ---------- */
const angDiff = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };
Cam.flyTo = function (to, opts = {}) {
const from = Cam.snapshot();
const tgt = { ...from, ...to };
const dist = geo.dist(from.lat, from.lon, tgt.lat, tgt.lon);
let dur = opts.duration ?? clamp(1.1 + Math.abs(Math.log(tgt.range / from.range)) * 0.22 + dist * 0.55, 1.0, 3.4);
if (prefersReduced() || opts.instant) dur = 0;
const peak = Math.max(from.range, tgt.range, dist * 1.25);
Cam.vel = null;
return new Promise((res) => {
if (dur === 0) { Cam.set(tgt); res(); return; }
Cam.anim = { from, to: tgt, t: 0, dur, peak, hump: peak > Math.max(from.range, tgt.range) * 1.05, res, va: geo.vec(from.lat, from.lon), vb: geo.vec(tgt.lat, tgt.lon), dh: angDiff(from.heading, tgt.heading) };
});
};
Cam.cancel = () => { if (Cam.anim) { const r = Cam.anim.res; Cam.anim = null; r && r(); } };
Cam.update = function (dt) {
const A = Cam.anim;
if (A) {
A.t += dt; const u = clamp(A.t / A.dur, 0, 1); const e = ease.inOut(u);
const pe = A.hump ? ease.inOutSine(clamp((u - 0.08) / 0.84, 0, 1)) : e;
const p = geo.slerp(A.va, A.vb, pe); const [la, lo] = geo.ll(p);
Cam.lat = la; Cam.lon = lo;
const l0 = Math.log(A.from.range), l1 = Math.log(A.to.range), lm = Math.log(A.peak);
Cam.range = A.hump ? Math.exp((1 - e) * (1 - e) * l0 + 2 * (1 - e) * e * lm + e * e * l1) : Math.exp(lerp(l0, l1, e));
Cam.heading = A.from.heading + A.dh * e;
const tiltMid = A.hump ? Math.sin(Math.PI * e) : 0;
Cam.tilt = lerp(A.from.tilt, A.to.tilt, e) * (1 - tiltMid * 0.85);
Cam.shiftY = lerp(A.from.shiftY, A.to.shiftY, e); Cam.shiftX = lerp(A.from.shiftX, A.to.shiftX, e);
if (u >= 1) { Cam.anim = null; Object.assign(Cam, { heading: A.to.heading, tilt: A.to.tilt, range: A.to.range, lat: A.to.lat, lon: A.to.lon }); A.res(); }
return true;
}
if (Cam.rig) { Cam.rigBlend = Math.min(1, Cam.rigBlend + dt / (Cam.rig.blendTime || 1.4)); return true; }
if (Cam.vel) {
const v = Cam.vel; const k = Math.exp(-dt * 3.2);
Cam.lat = clamp(Cam.lat + v.lat * dt, -86, 86); Cam.lon = ((Cam.lon + v.lon * dt + 540) % 360) - 180; Cam.heading += v.h * dt;
v.lat *= k; v.lon *= k; v.h *= k;
if (Math.abs(v.lat) + Math.abs(v.lon) + Math.abs(v.h) < 1e-4) Cam.vel = null;
return true;
}
if (Cam.autoSpin && !Cam.interacting && performance.now() - Cam.lastInput > 2500) { Cam.lon = ((Cam.lon + dt * Cam.autoSpin + 540) % 360) - 180; return true; }
return false;
};
Cam.setRig = function (rig) { // rig.pose() → {eye, back, up, fov}
Cam.rigFrom = rig ? (Cam.rig ? Cam.view(400, 800) : Cam.basis()) : null;
if (rig) { Cam.anim = null; Cam.vel = null; }
Cam.rig = rig; Cam.rigBlend = prefersReduced() ? 1 : 0;
};
// pose helper: eye at lat/lon/alt, looking along heading/pitch (rad), optional roll
Cam.pose = function (lat, lon, altKm, hdg, pitch, roll = 0, fov) {
const eye = geo.vec(lat, lon, altKm); const { east, north, up } = geo.enu(lat, lon);
const F0 = v3.add(v3.scale(north, Math.cos(hdg)), v3.scale(east, Math.sin(hdg)));
const fwd = v3.norm(v3.add(v3.scale(F0, Math.cos(pitch)), v3.scale(up, Math.sin(pitch))));
let upv = v3.norm(v3.sub(up, v3.scale(fwd, v3.dot(up, fwd))));
let right = v3.norm(v3.cross(fwd, upv));
if (roll) { const c = Math.cos(roll), s = Math.sin(roll); const u2 = v3.add(v3.scale(upv, c), v3.scale(right, s)); right = v3.norm(v3.cross(fwd, u2)); upv = u2; }
return { eye, back: v3.scale(fwd, -1), up: upv, fov };
};
/* ---------- input ---------- */
Cam.attach = function (el, getView) {
const pts = new Map(); let last = null, moved = 0, downAt = 0, samples = [];
const metersPx = (view) => (Cam.range * 2 * Math.tan(view.fovY / 2)) / view.h * (1 + Cam.range * 0.18);
const panBy = (dx, dy, view) => {
const k = metersPx(view);
const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading);
const tiltK = 1 + Math.min(Cam.tilt, Cam.maxTilt(Cam.range)) * 0.8;
const de = (-dx * ch + dy * sh * tiltK) * k, dn = (dx * sh + dy * ch * tiltK) * k;
const dLat = dn * R2D, dLon = (de * R2D) / Math.max(0.15, Math.cos(Cam.lat * D2R));
Cam.lat = clamp(Cam.lat + dLat, -86, 86); Cam.lon = ((Cam.lon + dLon + 540) % 360) - 180;
return [dLat, dLon];
};
const zoomAt = (ratio, x, y, view) => {
const g = Cam.groundAt(view, x, y);
const nr = clamp(Cam.range * ratio, Cam.minRange, Cam.maxRange); const f = 1 - nr / Cam.range;
if (g && f > 0) { const p = geo.slerp(geo.vec(Cam.lat, Cam.lon), geo.vec(g[0], g[1]), f * 0.92); const [la, lo] = geo.ll(p); Cam.lat = la; Cam.lon = lo; }
Cam.range = nr;
};
el.addEventListener('pointerdown', (e) => {
if (!Cam.enabled) return;
el.setPointerCapture?.(e.pointerId);
pts.set(e.pointerId, { x: e.clientX, y: e.clientY, b: e.button });
Cam.cancel(); Cam.vel = null; Cam.interacting = true; Cam.lastInput = performance.now();
if (pts.size === 1) { moved = 0; downAt = performance.now(); samples = []; }
last = null; bus.emit('globe-touch');
});
el.addEventListener('pointermove', (e) => {
if (!pts.has(e.pointerId) || !Cam.enabled) return;
const view = getView(); const p = pts.get(e.pointerId); const nx = e.clientX, ny = e.clientY;
const dx = nx - p.x, dy = ny - p.y;
if (pts.size === 1) {
moved += Math.abs(dx) + Math.abs(dy);
if (Cam.rig && Cam.rig.look) { Cam.rig.look(dx, dy); }
else if (p.b === 2 || e.shiftKey || e.ctrlKey) { Cam.heading -= dx * 0.006; Cam.tilt = clamp(Cam.tilt + dy * 0.005, 0, Cam.maxTilt(Cam.range)); }
else { const [dl, dn] = panBy(dx, dy, view); samples.push({ t: performance.now(), dl, dn }); }
p.x = nx; p.y = ny;
} else if (pts.size === 2) {
const ids = [...pts.keys()]; const a = pts.get(ids[0]), b = pts.get(ids[1]);
const before = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
p.x = nx; p.y = ny;
const after = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
const ratio = before.d / Math.max(after.d, 1);
const mdx = after.mx - before.mx, mdy = after.my - before.my;
if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(ratio); }
else {
if (Math.abs(ratio - 1) < 0.006 && Math.abs(mdy) > Math.abs(mdx) * 1.4) Cam.tilt = clamp(Cam.tilt - mdy * 0.006, 0, Cam.maxTilt(Cam.range));
else { zoomAt(ratio, after.mx, after.my, view); panBy(mdx, mdy, view); Cam.heading += angDiff(before.ang, after.ang) * -1; }
}
moved += 99;
}
Cam.lastInput = performance.now();
bus.emit('camera-move');
});
const up = (e) => {
if (!pts.has(e.pointerId)) return;
const wasSingle = pts.size === 1; pts.delete(e.pointerId);
if (wasSingle) {
Cam.interacting = false;
const dtp = performance.now() - downAt;
if (moved < 9 && dtp < 450) { const r = el.getBoundingClientRect(); bus.emit('tap', { x: e.clientX - r.left, y: e.clientY - r.top }); }
else if (!Cam.rig) {
const now = performance.now(); const rec = samples.filter((s) => now - s.t < 90);
if (rec.length > 1) { const span = (rec[rec.length - 1].t - rec[0].t) / 1000 || 0.016; const sl = rec.reduce((s, x) => s + x.dl, 0) / span, sn = rec.reduce((s, x) => s + x.dn, 0) / span; Cam.vel = { lat: clamp(sl, -90, 90), lon: clamp(sn, -180, 180), h: 0 }; if (prefersReduced()) Cam.vel = null; }
}
}
};
el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
el.addEventListener('contextmenu', (e) => e.preventDefault());
el.addEventListener('wheel', (e) => {
if (!Cam.enabled) return; e.preventDefault(); Cam.cancel(); Cam.vel = null; Cam.lastInput = performance.now();
if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(Math.exp(e.deltaY * 0.0015)); return; }
const r = el.getBoundingClientRect(); zoomAt(Math.exp(e.deltaY * 0.0016), e.clientX - r.left, e.clientY - r.top, getView()); bus.emit('camera-move');
}, { passive: false });
};
+227
View File
@@ -0,0 +1,227 @@
/* Situations — the feed's genres (DESIGN.md §9). Live metrics are computed from the synthetic world;
names, handles, posts and reconstruction claims are illustrative. */
const SIT = (GE.SIT = {});
const fam = (f) => GE.FAM[f].color;
const RSit = rng(4242);
function walk(n, start, vol, drift = 0, seed = 1) { const r = rng(seed); let v = start; const out = []; for (let i = 0; i < n; i++) { v += r.gauss() * vol + drift; out.push(v); } return out; }
/* user locale ("Near you") from time zone only — no geolocation */
const TZMAP = { 'America/Los_Angeles': ['San Francisco', 37.77, -122.42], 'America/New_York': ['New York', 40.71, -74.0], 'America/Chicago': ['Chicago', 41.88, -87.63], 'America/Denver': ['Denver', 39.74, -104.99], 'Europe/London': ['London', 51.51, -0.13], 'Europe/Paris': ['Paris', 48.86, 2.35], 'Europe/Berlin': ['Berlin', 52.52, 13.4], 'Asia/Tokyo': ['Tokyo', 35.68, 139.69], 'Asia/Kolkata': ['Delhi', 28.61, 77.21], 'Asia/Kathmandu': ['Kathmandu', 27.72, 85.32], 'Australia/Sydney': ['Sydney', -33.87, 151.21], 'Asia/Singapore': ['Singapore', 1.35, 103.82] };
let tz = 'UTC'; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch {}
const ME = TZMAP[tz] || ['London', 51.51, -0.13];
GE.ME = { name: ME[0], lat: ME[1], lon: ME[2], tz };
/* ISS pass prediction over "you" on the synthetic orbit */
function issPass(fromMs) {
const obs = geo.vec(GE.ME.lat, GE.ME.lon); const up = v3.norm(obs);
let best = null, inPass = false, cur = null;
for (let s = 0; s < 36 * 3600; s += 20) {
const ms = fromMs + s * 1000, t = (ms - NOW) / 1000; const g = astro.sun(ms).gmst;
const p = GE.satAt(GE.ISS, t, g); const d = v3.norm(v3.sub(p, obs)); const el = Math.asin(v3.dot(d, up)) * R2D;
if (el > 10) { if (!inPass) { inPass = true; cur = { start: ms, max: el, maxAt: ms }; } if (el > cur.max) { cur.max = el; cur.maxAt = ms; } }
else if (inPass) { inPass = false; cur.end = ms; if (!best && cur.max > 18) { best = cur; break; } }
}
return best || { start: fromMs + 3.2 * 3600000, max: 41, maxAt: fromMs + 3.25 * 3600000, end: fromMs + 3.3 * 3600000 };
}
function busiestAirport() {
const t = GE.world.t; const counts = new Map();
for (const f of GE.FLIGHTS) { if (f.kind !== 0) continue; const p = GE.flightAt(f, t); if (!p) continue; for (const ap of [p.from, p.to]) { if (!ap.iata || ap.iata.length !== 3) continue; const d = geo.dist(p.lat, p.lon, ap.lat, ap.lon) * EARTH_KM; if (d < 160) counts.set(ap.iata, (counts.get(ap.iata) || 0) + 1); } }
let best = 'ATL', bc = 0; for (const [k, v] of counts) if (v > bc) { bc = v; best = k; }
return { iata: best, ap: GE.apByIata[best], n: bc, perH: Math.round(bc * 5.6) };
}
SIT.issPass = issPass; SIT.busiest = busiestAirport;
// daytime replay helper: if it is night at a place now, use the most recent local afternoon
function dayReplay(lat, lon, localHour = 15.5) {
if (astro.elev(NOW, lat, lon) > 8) return null;
const tzh = Math.round(lon / 15); const d = new Date(NOW); let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0) + (localHour - tzh) * 3600000;
while (t > NOW) t -= 86400000; return { at: t, start: t - 3 * 3600000, end: t + 3 * 3600000, replay: true };
}
SIT.dayReplay = dayReplay;
const LAUNCH_T0 = NOW + (2 * 3600 + 14 * 60 + 33) * 1000;
const KOSHI_T = GE.scenes.FLOOD.startMs;
const AURORA_T = (() => { const d = new Date(NOW); d.setUTCHours(21, 30, 0, 0); if (d.getTime() < NOW) d.setUTCDate(d.getUTCDate() + 1); return d.getTime(); })();
/* ---------- situation definitions ---------- */
const S = [];
const add = (o) => { o.updatedAt = o.updatedAt || NOW - (o.ageS || 60) * 1000; S.push(o); return o; };
add({
id: 'cyclone', short: 'Typhoon Halcyon', fam: 'haz', kind: 'cyclone', section: 'now', live: true, aspect: 1.25, ageS: 120, watchers: 48200,
title: 'Typhoon Halcyon tracks toward Luzon Strait', place: 'Philippine Sea', lat: 16.2, lon: 128.8,
camera: { lat: 15.2, lon: 127.6, range: 0.42, heading: -0.25, tilt: 0.42 }, thumbCam: { lat: 16.6, lon: 128.4, range: 0.3, heading: -0.2, tilt: 0.36 },
layers: ['cyclones', 'radar', 'lightning', 'clouds', 'models', 'ais', 'flights'], source: 'Advisory feed (illustrative)',
metric: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [`${Math.round(c.wind)}<small>km/h</small>`, `${Math.round(c.p)} hPa · moving WNW 19 km/h`]; },
metrics: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [{ v: Math.round(c.wind), u: 'km/h', l: 'Max sustained', s: 'Advisory · 2 min' }, { v: Math.round(c.p), u: 'hPa', l: 'Central pressure', s: 'Advisory · 2 min' }, { v: 610, u: 'km', l: 'To Luzon Strait', s: 'Computed · now' }]; },
accessory: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return `${Math.round(c.wind)} km/h · ${Math.round(c.p)} hPa · updated 2 min ago`; },
brief: {
lede: 'A compact, intense typhoon is moving west-northwest at 19 km/h. The forecast cone widens to ±370 km by day 5; most guidance recurves it north of Luzon before a weakening landfall near Taiwan. Ships are routing south of the track; two vessels are holding off Batanes.',
series: [
{ label: 'Max wind', unit: 'km/h', data: GE.CYCLONE.track.map((x) => x[3]), split: 6, band: 18, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v) },
{ label: 'Pressure', unit: 'hPa', data: GE.CYCLONE.track.map((x) => x[4]), split: 6, band: 6, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v), invert: true },
{ label: 'Ships within 500 km', unit: '', data: walk(24, 40, 2, -0.6, 3).map((v) => Math.max(0, Math.round(v))), src: 'AIS · 5 min bins · 40 s', fmt: (v) => v },
],
multiples: { kind: 'globe', label: 'Track, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? '<b>Now</b>' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h' })) },
sources: [['Tropical cyclone advisory (illustrative)', '2 min', 'live'], ['Geostationary IR mosaic', '10 min', 'live'], ['AIS vessel positions', '40 s', 'live'], ['Ensemble forecast tracks (14 members)', '3 h', 'stale']],
},
});
add({
id: 'quake', short: 'M 6.4 · Honshu', fam: 'haz', kind: 'quake', section: 'now', live: true, aspect: 1.0, ageS: 5.4 * 3600, watchers: 21900,
title: 'M 6.4 off the coast of Honshu — largest in 24 h', place: 'Off eastern Honshu, Japan', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon,
camera: { lat: 35.2, lon: 141.0, range: 0.13, heading: 0.35, tilt: 0.75 }, layers: ['quakes', 'flights', 'ais', 'cctv'], source: 'Seismic network (illustrative)',
metric: () => [`M 6.4`, `32 km deep · 9 aftershocks`], metrics: () => [{ v: '6.4', u: 'Mw', l: 'Magnitude', s: 'Seismic net · 5 h' }, { v: 32, u: 'km', l: 'Depth', s: 'Seismic net · 5 h' }, { v: 9, u: '', l: 'Aftershocks ≥ M3.4', s: 'Seismic net · 12 min' }],
brief: {
lede: 'A shallow thrust event under the Pacific margin, felt widely along the coast. No tsunami was generated beyond 20 cm. Rail services paused for inspection and resumed within 90 minutes; flights into HND were briefly held.',
series: [{ label: 'Aftershocks per hour', unit: '', data: [0, 0, 0, 0, 0, 7, 5, 3, 3, 2, 2, 1, 1, 2, 1, 0, 1, 1], src: 'Seismic catalog · hourly · 12 min', fmt: (v) => v }, { label: 'Coastal tide gauge', unit: 'cm', data: walk(36, 0, 1.2, 0, 7).map((v, i) => (i > 18 && i < 26 ? v + 14 * Math.sin((i - 18) / 8 * Math.PI) : v)), band: 3, src: 'Tide gauge (illustrative) · 1 min · 3 min', fmt: (v) => v.toFixed(0) }],
multiples: { kind: 'bars', label: 'Felt reports by prefecture', items: [['Fukushima', 0.9], ['Ibaraki', 0.75], ['Miyagi', 0.6], ['Chiba', 0.45]] },
sources: [['Seismic network catalog (illustrative)', '12 min', 'live'], ['Tide gauges', '3 min', 'live'], ['Rail status boards', '9 min', 'live']],
},
});
add({
id: 'fires', short: 'Fires · Iberia', fam: 'haz', kind: 'fires', section: 'now', live: true, aspect: 1.33, ageS: 38 * 60, watchers: 9800,
title: 'Wildfire clusters across Iberia and Greece', place: 'Southern Europe', lat: 40.0, lon: -6.5,
camera: { lat: 39.2, lon: -6.0, range: 0.18, heading: 0.1, tilt: 0.55 }, layers: ['fires', 'perims', 'aq', 'wind', 'flights'], source: 'VIIRS 375 m hotspots',
metric: () => [`${fmt.n(GE.FIRES.filter((f) => f.t <= GE.world.t && (f.cluster === 'Central Portugal' || f.cluster === 'Galicia' || f.cluster === 'Attica' || f.cluster === 'Epirus' || f.cluster === 'Antalya')).length)}<small>hotspots</small>`, 'Portugal · Galicia · Attica · Antalya'],
metrics: () => [{ v: GE.FIRES.filter((f) => f.t <= GE.world.t && f.lat > 34 && f.lon > -10 && f.lon < 32).length, u: '', l: 'Active hotspots', s: 'VIIRS · 38 min' }, { v: '3.4k', u: 'ha', l: 'Mapped perimeter', s: 'Perimeters · 3 h' }, { v: 31, u: 'km/h', l: 'Wind, Galicia', s: 'Model · 1 h' }],
brief: { lede: 'Dry easterly winds are pushing fire fronts toward the Atlantic in central Portugal and Galicia. Smoke is degrading air quality in Porto; the Attica cluster is contained on its north flank.',
series: [{ label: 'Hotspots, 6 h bins', unit: '', data: [12, 18, 44, 61, 58, 80, 102, 96, 120, 140, 133, 151], src: 'VIIRS / MODIS · 6 h · 38 min', fmt: (v) => v }, { label: 'PM2.5 Porto', unit: 'µg/m³', data: walk(30, 14, 3, 1.4, 11), band: 8, src: 'Air-quality sensors · 1 h · 22 min', fmt: (v) => Math.round(v) }],
multiples: { kind: 'globe', label: 'Spread, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? '<b>Now</b>' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h', cam: { lat: 40.6, lon: -7.8, range: 0.05, heading: 0, tilt: 0.3 } })) },
sources: [['VIIRS 375 m active fires', '38 min', 'live'], ['Fire perimeters (illustrative)', '3 h', 'stale'], ['Wind model', '1 h', 'live']] },
});
add({
id: 'milair', short: 'Military air · Baltic', fam: 'sky', kind: 'mil', section: 'now', live: true, aspect: 1.0, ageS: 30, watchers: 17300,
title: 'Military air activity over the Baltic', place: 'Baltic Sea', lat: 56.5, lon: 19.5,
camera: { lat: 56.0, lon: 19.0, range: 0.16, heading: 0.0, tilt: 0.5 }, layers: ['military', 'flights', 'gnss', 'holding'], source: 'ADS-B / MLAT',
metric: () => [`38<small>aircraft</small>`, '3 tankers holding · GNSS jamming'], metrics: () => [{ v: 38, u: '', l: 'Military aircraft', s: 'ADS-B/MLAT · 30 s' }, { v: 3, u: '', l: 'Tankers in orbit', s: 'ADS-B · 30 s' }, { v: '41', u: '%', l: 'Flights w/ GNSS loss', s: 'NIC/NACp · 10 min' }],
brief: { lede: 'Two tanker orbits are active over southern Sweden with fighter pairs cycling. GNSS integrity reports from airliners show a jamming footprint centred off Kaliningrad, affecting 41% of overflights in the last hour.',
series: [{ label: 'Military aircraft aloft', unit: '', data: walk(36, 26, 2, 0.35, 21).map(Math.round), src: 'ADS-B / MLAT · 10 min · 30 s', fmt: (v) => v }, { label: 'GNSS-degraded flights', unit: '%', data: walk(36, 30, 2.4, 0.3, 23), band: 6, src: 'Aircraft integrity reports · 10 min', fmt: (v) => Math.round(v) }],
multiples: { kind: 'bars', label: 'By type', items: [['Fighters', 0.8], ['Tankers', 0.22], ['ISR', 0.3], ['Transport', 0.5]] },
sources: [['ADS-B / MLAT network', '30 s', 'live'], ['GNSS integrity (NIC/NACp)', '10 min', 'live']] },
});
add({
id: 'iss', fam: 'orbit', kind: 'iss', section: 'near', live: true, aspect: 1.33, ageS: 4, watchers: 6400,
title: `ISS pass over ${GE.ME.name}`, place: `${GE.ME.name} (from your time zone)`, lat: GE.ME.lat, lon: GE.ME.lon,
camera: { lat: GE.ME.lat, lon: GE.ME.lon, range: 0.55, heading: 0, tilt: 0.55 }, layers: ['crewed', 'tracks', 'sats', 'trains'], source: 'TLE propagation',
dyn: true,
metric: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); const s = (p.start - Date.now()) / 1000; return [s > 0 ? `in ${Math.floor(s / 3600)}<small>h</small> ${Math.floor((s % 3600) / 60)}<small>m</small>` : 'now', `max ${Math.round(p.max)}° · ${fmt.hm(p.maxAt)} UTC`]; },
metrics: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); return [{ v: fmt.hm(p.start), u: 'UTC', l: 'Rises', s: 'TLE · 4 s' }, { v: Math.round(p.max), u: '°', l: 'Max elevation', s: 'Computed' }, { v: Math.round((p.end - p.start) / 60000), u: 'min', l: 'Visible for', s: 'Computed' }]; },
brief: { lede: 'The station will rise in the west-southwest and climb high across your sky. If the sky is clear and the pass is after dusk it will be the brightest point-like object, brighter than any star.', series: [{ label: 'Elevation during pass', unit: '°', data: Array.from({ length: 30 }, (_, i) => Math.max(0, Math.sin((i / 29) * Math.PI)) * 54), src: 'Propagated from TLE · computed', fmt: (v) => Math.round(v) }], sources: [['Two-line elements (illustrative epoch)', '4 h', 'live'], ['Sun/terminator', 'computed', 'live']] },
});
add({
id: 'airport', short: 'Busiest: ATL', fam: 'sky', kind: 'airport', section: 'always', live: true, aspect: 1.25, ageS: 20, watchers: 5200,
title: 'Busiest airport right now', place: 'Hartsfield–Jackson Atlanta', lat: 33.6407, lon: -84.4277,
camera: { lat: 33.636, lon: -84.4277, range: 0.0042, heading: 0.35, tilt: 1.05 }, layers: ['flights', 'airports', 'holding', 'traffic'], source: 'ADS-B',
metric: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [`${b.iata} <small>${b.perH} mvts/h</small>`, `${b.ap.city} · ${b.n} aircraft within 160 km`]; },
metrics: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [{ v: b.perH, u: '/h', l: 'Movements', s: 'ADS-B · 20 s' }, { v: b.n, u: '', l: 'Aircraft < 160 km', s: 'ADS-B · 20 s' }, { v: 4, u: 'min', l: 'Avg. taxi-out delay', s: 'Derived · 15 min' }]; },
brief: { lede: 'Arrivals are landing on the two southern runways in a steady stream; departures are pushing west. The evening bank peaks in 40 minutes.', series: [{ label: 'Movements per hour', unit: '', data: [62, 70, 88, 94, 101, 97, 90, 85, 92, 104, 112, 108], src: 'ADS-B · hourly · 20 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'Top 4 right now', items: [['ATL', 0.95], ['DFW', 0.84], ['DXB', 0.8], ['LHR', 0.74]] }, sources: [['ADS-B network', '20 s', 'live'], ['Airport board (scraped, illustrative)', '2 min', 'live']] },
});
add({
id: 'novosel', short: 'Fort Novosel circuits', fam: 'sky', kind: 'heli', section: 'always', live: true, aspect: 1.5, ageS: 15, watchers: 3100, ride: true,
title: 'Fort Novosel helicopter school — jump into the chopper', place: 'Cairns Army Airfield, Alabama', lat: 31.276, lon: -85.713,
camera: { lat: 31.268, lon: -85.705, range: 0.0019, heading: -0.6, tilt: 1.12 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2),
metric: () => [`${GE.HELI_CIRCUITS.filter((h) => h.field).length}<small>in circuits</small>`, 'Cairns + 3 stagefields · 500 ft AGL'],
metrics: () => [{ v: GE.HELI_CIRCUITS.filter((h) => h.field).length, u: '', l: 'Helicopters in circuits', s: 'ADS-B · 15 s' }, { v: 500, u: 'ft', l: 'Pattern altitude', s: 'Observed' }, { v: 5.5, u: 'min', l: 'Circuit time', s: 'Derived' }],
brief: { lede: 'Student pilots are flying rectangular traffic patterns at Cairns and three stagefields: upwind, crosswind, downwind, base, final — over and over. Tap Ride to sit in the left seat of one of them.', series: [{ label: 'Helicopters aloft', unit: '', data: [2, 4, 9, 14, 17, 19, 19, 18, 19, 17, 19, 19], src: 'ADS-B / MLAT · hourly · 15 s', fmt: (v) => v }, { label: 'Groundspeed, HAWK 21', unit: 'kt', data: Array.from({ length: 40 }, (_, i) => 70 + 12 * Math.sin(i / 3.2) + (i % 10 > 7 ? -30 : 0)), src: 'ADS-B · 1 s · 15 s', fmt: (v) => Math.round(v) }], sources: [['ADS-B / MLAT', '15 s', 'live']] },
});
add({
id: 'launch', short: 'Launch · SLC-40', fam: 'orbit', kind: 'launch', section: 'now', live: true, aspect: 1.5, ageS: 300, watchers: 33800,
title: 'Rideshare launch from SLC-40 — countdown', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
camera: { lat: 28.552, lon: -80.590, range: 0.0011, heading: 0.75, tilt: 1.2 }, layers: ['launches', 'flights', 'ais', 'tracks'], source: 'Range schedule (illustrative)',
metric: () => [`<span class="num">T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}</span>`, 'Rideshare to SSO · trajectory NE'],
metrics: () => [{ v: fmt.dur((LAUNCH_T0 - Date.now()) / 1000), u: '', l: 'Countdown', s: 'Range · 5 min' }, { v: 92, u: '%', l: 'Weather go', s: 'Forecast · 1 h' }, { v: 14, u: '', l: 'Vessels in hazard area', s: 'AIS · 40 s' }],
brief: { lede: 'Vehicle is vertical on the pad. The hazard area offshore is still being cleared — 14 vessels remain inside. Trajectory heads northeast along the coast.', series: [{ label: 'Vessels in hazard area', unit: '', data: [41, 39, 36, 33, 31, 28, 24, 21, 19, 17, 15, 14], src: 'AIS · 15 min · 40 s', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['AIS', '40 s', 'live']] },
});
add({
id: 'koshi', short: 'Bhote Koshi · 4D', fam: 'haz', kind: 'recon', section: 'recon', live: false, aspect: 1.33, ageS: 53 * 86400, watchers: 12800, thread: 'koshi', d4: true,
title: 'Bhote Koshi outburst flood — 4D reconstruction', place: 'Rasuwa, Nepal · 26 Aug 2026', lat: 28.215, lon: 85.36,
camera: { lat: 28.212, lon: 85.352, range: 0.0036, heading: 0.35, tilt: 1.08 }, time: { at: KOSHI_T + 2.2 * 3600000, start: KOSHI_T - 3 * 3600000, end: KOSHI_T + 9 * 3600000 },
layers: ['signed', 'holo', 'floods', 'helis'], source: 'Thread · 23 observations',
metric: () => ['23<small>obs</small> · 9<small>hyp</small>', '14 people · 3 AI proposals'], metrics: () => [{ v: 23, u: '', l: 'Observations', s: 'Ledger · 2 h' }, { v: 9, u: '', l: 'Hypotheses', s: 'Ledger · 2 h' }, { v: 14, u: '', l: 'Contributors', s: 'Thread' }],
brief: { lede: 'An outburst flood was observed on the Bhote Koshi on 26 August 2026. This thread reconstructs the crest’s passage from Rasuwagadhi down to Syaphrubesi using signed witness captures, river-gauge readings and satellite imagery. Witness posts are illustrative.', series: [{ label: 'River stage, Syaphrubesi (illustrative)', unit: 'm', data: Array.from({ length: 40 }, (_, i) => 3.1 + 4.7 * Math.exp(-Math.pow((i - 16) / 5, 2)) + (i > 16 ? 0.6 : 0)), band: 0.4, src: 'Gauge (illustrative) · 15 min', fmt: (v) => v.toFixed(1) }], sources: [['Signed witness captures (illustrative)', '53 d', 'stale'], ['Optical satellite tiles, before/after', '50 d', 'stale']] },
});
add({
id: 'concert', short: 'Stadium encore · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.25, ageS: 20 * 3600, watchers: 88200, thread: 'concert', d4: true,
title: 'Stadium encore in 4D — from 2,311 crowd captures', place: 'Wembley, London · last night', lat: 51.556, lon: -0.2796,
camera: { lat: 51.5552, lon: -0.2810, range: 0.00034, heading: 0.55, tilt: 1.12 }, time: { at: NOW - 20 * 3600000 + 0.0, start: NOW - 22 * 3600000, end: NOW - 19 * 3600000 },
layers: ['signed', 'holo', 'venues', 'cctv'], source: 'Thread · 2,311 signed captures', nightThumb: true,
metric: () => ['2,311<small>captures</small>', '4D · 41 min · 86 seat blocks'], metrics: () => [{ v: '2,311', u: '', l: 'Signed captures', s: 'Ledger · 20 h' }, { v: 86, u: '', l: 'Seat blocks covered', s: 'Derived' }, { v: '±9', u: 'ms', l: 'Time alignment', s: 'Audio sync' }],
brief: { lede: 'A crowd-sourced reconstruction of last night’s encore: 2,311 signed phone captures aligned by audio fingerprint and IMU traces into a single 4D scene you can scrub. Performer and attendee identities are not part of the record.', series: [{ label: 'Captures per minute', unit: '', data: [3, 5, 9, 21, 64, 140, 188, 162, 120, 96, 72, 40], src: 'Signed bundles · 1 min', fmt: (v) => v }], sources: [['Signed crowd captures (illustrative)', '20 h', 'stale']] },
});
add({
id: 'climb', short: 'Tower climb · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.5, ageS: 9 * 3600, watchers: 41500, thread: 'climb', d4: true,
title: 'Free-climber on a Midtown tower — reconstructed', place: 'Midtown Manhattan · this morning', lat: 40.7553, lon: -73.9838,
camera: { lat: 40.7528, lon: -73.9870, range: 0.00027, heading: 0.62, tilt: 1.2 }, time: { at: NOW - 9 * 3600000, start: NOW - 11 * 3600000, end: NOW - 7 * 3600000 },
layers: ['helis', 'signed', 'holo', 'cctv'], source: 'Thread · 3 news helicopters (ADS-B)',
metric: () => ['41<small>clips</small> · 3<small>helis</small>', 'Fictional tower · ascent 2 h 14 m'], metrics: () => [{ v: 41, u: '', l: 'Witness clips', s: 'Ledger · 9 h' }, { v: 3, u: '', l: 'News helicopters', s: 'ADS-B · 9 h' }, { v: 412, u: 'm', l: 'Tower height', s: 'Model (fictional tower)' }],
brief: { lede: 'Three news helicopters orbited the tower for two hours; their ADS-B tracks give precise camera positions for every broadcast frame. Combined with 41 signed street-level clips, the climber’s height over time is reconstructed to ±3 m. The tower and people are fictional.', series: [{ label: 'Climber height (reconstructed)', unit: 'm', data: Array.from({ length: 30 }, (_, i) => Math.min(380, i * 14 + Math.sin(i) * 6)), band: 3, src: 'Hypothesis H2 · ±3 m', fmt: (v) => Math.round(v) }], sources: [['ADS-B: 3 helicopters', '9 h', 'stale'], ['Signed street clips (illustrative)', '9 h', 'stale']] },
});
add({
id: 'suez', short: 'Suez queue', fam: 'sea', kind: 'queue', section: 'always', live: true, aspect: 1.0, ageS: 40, watchers: 7700,
title: 'Ships queuing at Suez', place: 'Gulf of Suez & Port Said anchorages', lat: 30.3, lon: 32.4,
camera: { lat: 29.75, lon: 32.45, range: 0.045, heading: 0.2, tilt: 0.85 }, layers: ['ais', 'tankers', 'ports'], source: 'AIS',
metric: () => [`${GE.SHIPS.filter((s) => s.queue === 'suez').length}<small>waiting</small>`, 'avg wait 31 h · southbound convoy 06:00'],
metrics: () => [{ v: GE.SHIPS.filter((s) => s.queue === 'suez').length, u: '', l: 'Ships at anchor', s: 'AIS · 40 s' }, { v: 31, u: 'h', l: 'Average wait', s: 'Derived · 1 h' }, { v: 52, u: '', l: 'Transits / 24 h', s: 'AIS · 1 h' }],
brief: { lede: 'The anchorage off Suez is fuller than its 30-day median; northbound tankers are waiting longest.', series: [{ label: 'Ships at anchor', unit: '', data: walk(30, 52, 2.2, 0.75, 31).map(Math.round), band: 5, src: 'AIS · 6 h · 40 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'By type', items: [['Tanker', 0.82], ['Bulk', 0.6], ['Container', 0.45], ['LNG', 0.22]] }, sources: [['AIS terrestrial + satellite', '40 s', 'live']] },
});
add({
id: 'aurora', short: 'Aurora · Kp 6', fam: 'wx', kind: 'aurora', section: 'near', live: true, aspect: 1.33, ageS: 600, watchers: 15100,
title: 'Aurora nowcast — Kp 6 expected tonight', place: 'Northern Scandinavia & Iceland', lat: 69.6, lon: 18.9,
camera: { lat: 62.5, lon: 12.0, range: 0.62, heading: 0.15, tilt: 0.95 }, time: { at: AURORA_T }, layers: ['clouds', 'signed', 'holo', 'flights'], source: 'Space-weather forecast', auroraK: 1.15,
metric: () => ['Kp 6<small>tonight</small>', 'Tromsø 92% · cloud 18%'], metrics: () => [{ v: 6, u: 'Kp', l: 'Forecast peak', s: 'Space weather · 10 min' }, { v: 92, u: '%', l: 'Chance, Tromsø', s: 'Model · 10 min' }, { v: 18, u: '%', l: 'Cloud cover', s: 'Satellite · 15 min' }],
brief: { lede: 'A coronal mass ejection is arriving earlier than forecast. The auroral oval will push south after dark; sky captures from Tromsø already show a bright arc.', series: [{ label: 'Kp forecast', unit: '', data: [2, 2, 3, 3, 4, 5, 6, 6, 5, 4, 4, 3], band: 1, src: 'Space-weather forecast · 3 h · 10 min', fmt: (v) => v }], sources: [['Space-weather forecast', '10 min', 'live'], ['Sky captures (illustrative)', '18 min', 'live']] },
});
add({
id: 'drones', short: 'Drone corridor', fam: 'ground', kind: 'drone', section: 'always', live: false, aspect: 1.25, ageS: 3600, watchers: 2300,
title: 'Delivery-drone corridor — today’s replay', place: 'Suburban Texas (illustrative operator)', lat: 30.588, lon: -96.29,
camera: { lat: 30.578, lon: -96.300, range: 0.0013, heading: 0.5, tilt: 1.1 }, layers: ['drones', 'traffic'], source: 'Remote ID broadcasts', time: dayReplay(30.588, -96.29, 14.5),
metric: () => ['212<small>drops</small>', '16 drones · 4.1 min median'], metrics: () => [{ v: 212, u: '', l: 'Deliveries today', s: 'Remote ID · 1 h' }, { v: 16, u: '', l: 'Drones active', s: 'Remote ID · 1 min' }, { v: 4.1, u: 'min', l: 'Median trip', s: 'Derived' }],
brief: { lede: 'Remote ID broadcasts let anyone replay a corridor: out and back at 60 m, a hover-and-lower at each drop.', series: [{ label: 'Drops per hour', unit: '', data: [0, 0, 4, 12, 19, 22, 25, 21, 24, 27, 23, 18], src: 'Remote ID · hourly', fmt: (v) => v }], sources: [['Remote ID receivers (illustrative)', '1 min', 'live']] },
});
add({
id: 'marathon', short: 'Lakeshore 42K', fam: 'ground', kind: 'race', section: 'always', live: true, aspect: 1.0, ageS: 10, watchers: 4600,
title: 'Lakeshore 42K — runner trackers', place: 'Chicago lakefront (fictional race)', lat: 41.89, lon: -87.625,
camera: { lat: 41.875, lon: -87.630, range: 0.0026, heading: 0.15, tilt: 1.05 }, layers: ['venues', 'transit', 'traffic'], source: 'Race trackers (illustrative)', time: dayReplay(41.88, -87.63, 9.5),
metric: () => ['260<small>trackers</small>', 'leader at 31.4 km · 2:21 pace'], metrics: () => [{ v: 260, u: '', l: 'Live trackers', s: 'Race app · 10 s' }, { v: 31.4, u: 'km', l: 'Leader', s: '10 s' }, { v: '3:21', u: '/km', l: 'Median pace', s: 'Derived' }],
brief: { lede: 'Bibs broadcast positions every 10 seconds; the field has stretched to 9 km.', series: [{ label: 'Leader pace', unit: 's/km', data: walk(30, 200, 1.5, 0, 41), band: 2, src: 'Tracker · 1 km', fmt: (v) => Math.round(v) }], sources: [['Race trackers (fictional race)', '10 s', 'live']] },
});
add({
id: 'gnss', short: 'GNSS jamming', fam: 'infra', kind: 'gnss', section: 'now', live: true, aspect: 1.25, ageS: 600, watchers: 6100,
title: 'GNSS jamming hot spots', place: 'Baltic · Black Sea · Eastern Med', lat: 50, lon: 27,
camera: { lat: 49.0, lon: 26.0, range: 0.42, heading: 0.0, tilt: 0.35 }, layers: ['gnss', 'flights', 'military'], source: 'Aircraft integrity reports',
metric: () => ['8<small>hot spots</small>', 'Baltic 41% · E. Med 28%'], metrics: () => [{ v: 8, u: '', l: 'Hot spots', s: '10 min' }, { v: 41, u: '%', l: 'Baltic flights affected', s: '10 min' }, { v: 28, u: '%', l: 'E. Med', s: '10 min' }],
brief: { lede: 'Aircraft broadcast their own position-integrity; mapping drops in that integrity reveals where navigation signals are being jammed.', series: [{ label: 'Affected flights', unit: '%', data: walk(24, 30, 2, 0.4, 51), band: 5, src: 'NIC/NACp · 1 h', fmt: (v) => Math.round(v) }], sources: [['ADS-B integrity fields', '10 min', 'live']] },
});
add({
id: 'starlink', fam: 'orbit', kind: 'train', section: 'near', live: true, aspect: 1.0, ageS: 90, watchers: 8800,
title: 'Fresh satellite train — visible at dusk', place: 'Low Earth orbit, 312 km', lat: 30, lon: -60,
camera: null, layers: ['trains', 'sats', 'tracks'], source: 'TLE propagation', dyn: true,
metric: () => ['24<small>satellites</small>', 'orbit raising · 312 km'], metrics: () => [{ v: 24, u: '', l: 'Satellites in train', s: 'TLE · 90 s' }, { v: 312, u: 'km', l: 'Altitude', s: 'TLE' }, { v: 7.73, u: 'km/s', l: 'Speed', s: 'Computed' }],
brief: { lede: 'A freshly launched batch flies in a tight line before raising orbit — at dusk it looks like a string of pearls.', series: [{ label: 'Altitude (orbit raising)', unit: 'km', data: Array.from({ length: 20 }, (_, i) => 290 + i * 1.2), src: 'TLE · daily', fmt: (v) => Math.round(v) }], sources: [['Two-line elements', '90 s', 'live']] },
});
add({
id: 'squawk', fam: 'sky', kind: 'squawk', section: 'now', live: true, aspect: 1.25, ageS: 300, watchers: 12000,
title: 'Squawk 7700 — diversion in progress', place: 'Over the North Atlantic', lat: 50, lon: -30, dyn: true,
camera: null, layers: ['squawk', 'flights'], source: 'ADS-B',
metric: () => ['7700<small>emergency</small>', 'diverting · descending'], metrics: () => [{ v: 7700, u: '', l: 'Squawk', s: 'ADS-B · 5 s' }, { v: 'FL240', u: '', l: 'Descending through', s: '5 s' }, { v: 34, u: 'min', l: 'To diversion', s: 'Computed' }],
brief: { lede: 'An airliner set its transponder to 7700 and turned toward the nearest suitable airport. Replay the last 20 minutes.', series: [{ label: 'Altitude', unit: 'ft', data: Array.from({ length: 30 }, (_, i) => (i < 8 ? 37000 : 37000 - (i - 8) * 600)), src: 'ADS-B · 1 min', fmt: (v) => fmt.n(v) }], sources: [['ADS-B', '5 s', 'live']] },
});
add({
id: 'strait', short: 'Singapore Strait', fam: 'sea', kind: 'strait', section: 'always', live: true, aspect: 1.5, ageS: 40, watchers: 3900,
title: 'Singapore Strait — the world’s busiest lane', place: 'Singapore', lat: 1.25, lon: 103.9,
camera: { lat: 1.18, lon: 103.85, range: 0.022, heading: 0.1, tilt: 0.9 }, layers: ['ais', 'tankers', 'ports', 'flights'], source: 'AIS',
metric: () => ['1,912<small>transits/day</small>', '90 at anchor · 2 dark'], metrics: () => [{ v: '1,912', u: '', l: 'Transits / 24 h', s: 'AIS · 40 s' }, { v: 90, u: '', l: 'At anchor', s: 'AIS · 40 s' }, { v: 2, u: '', l: 'AIS gaps', s: '1 h' }],
brief: { lede: 'Two traffic separation lanes, one of the densest anchorages anywhere.', series: [{ label: 'Transits per hour', unit: '', data: walk(24, 80, 4, 0, 61).map(Math.round), src: 'AIS · hourly', fmt: (v) => v }], sources: [['AIS', '40 s', 'live']] },
});
for (const s of S) if (s.time && s.time.replay) { s.live = false; s.replayLabel = 'Today ' + new Date(s.time.at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }) + ' UTC'; }
SIT.list = S;
SIT.byId = Object.fromEntries(S.map((s) => [s.id, s]));
SIT.sections = [
{ id: 'now', title: 'Happening now', sub: 'Live' }, { id: 'near', title: 'Near you', sub: GE.ME.name }, { id: 'recon', title: 'Reconstructions', sub: '4D · signed' }, { id: 'always', title: 'Always on', sub: 'Worth a look' },
];
// dynamic cameras
SIT.cameraFor = function (s) {
if (s.id === 'starlink') { const g = GE.world.sun.gmst; const p = GE.satAt(GE.SATS.find((x) => x.kind === 5), GE.world.t, g); const [la, lo] = geo.ll(p); return { lat: la - 6, lon: lo - 8, range: 0.32, heading: 0.6, tilt: 0.85 }; }
if (s.id === 'squawk') { const f = GE.FLIGHTS[GE.world.squawks[0]]; const p = GE.flightAt(f, GE.world.t) || { lat: 50, lon: -30 }; return { lat: p.lat - 1.2, lon: p.lon - 1, range: 0.06, heading: 0.3, tilt: 0.85 }; }
if (s.id === 'iss') { const p = GE.satAt(GE.ISS, GE.world.t, GE.world.sun.gmst); const [la, lo] = geo.ll(p); return { lat: la - 9, lon: lo - 4, range: 0.48, heading: 0.35, tilt: 0.78 }; }
return s.camera;
};
SIT.pin = (s) => { if (s.id === 'starlink' || s.id === 'squawk' || s.id === 'iss') { const c = SIT.cameraFor(s); return null; } return { id: s.id, lat: s.lat, lon: s.lon, title: s.title, col: [...hex2rgb(fam(s.fam)), 1], seed: hash01(s.id.length) }; };
+147
View File
@@ -0,0 +1,147 @@
/* Shell: tabs, Liquid-Glass tab bar with sliding lens, tab-bar accessory (followed situation),
minimize-on-scroll, layout (phone / two-pane / hinge), overlay labels, toasts. */
const APP = (GE.app = { tab: 'now', mode: '', following: 'cyclone', wide: false });
const appEl = () => $('#app');
APP.isWide = () => { try { return matchMedia('(min-width: 600px)').matches; } catch { return innerWidth >= 600; } };
APP.toast = function (html, ms = 2200) {
const t = $('#toast'); t.innerHTML = html; t.classList.add('on'); clearTimeout(APP._tt); APP._tt = setTimeout(() => t.classList.remove('on'), ms);
};
APP.setMode = function (m) { APP.mode = m; appEl().dataset.mode = m; bus.emit('mode', m); APP.updateChrome(); };
/* ---------- tabs ---------- */
APP.setTab = function (tab, opts = {}) {
const prev = APP.tab;
if (prev === tab && !opts.force) { if (tab === 'now') $('#v-now').scrollTo({ top: 0, behavior: 'smooth' }); return; }
if (APP.mode === 'situation') GE.sheet.close(true);
if (APP.mode === 'ride') GE.ride.exit(true);
if (prev === 'capture') GE.capture.exit();
if (prev === 'threads' && GE.threads.ws) GE.threads.closeWs(true);
APP.tab = tab; appEl().dataset.tab = tab;
$$('.tab').forEach((b) => { b.classList.toggle('on', b.dataset.tab === tab); b.setAttribute('aria-selected', b.dataset.tab === tab); });
APP.moveLens();
const wide = APP.isWide();
$$('.pane .view').forEach((v) => v.classList.remove('on'));
const map = { now: '#v-now', threads: '#v-threads', you: '#v-you', globe: wide ? '#v-layers' : null };
if (map[tab]) $(map[tab]).classList.add('on');
$('#v-capture').classList.toggle('on', tab === 'capture');
if (tab === 'capture') GE.capture.enter();
if (tab === 'globe' && wide) GE.gui.renderLayersPane();
APP.minimize(false);
APP.cameraForTab(tab, prev);
APP.updateChrome();
haptic(6);
bus.emit('tab', tab);
};
APP.cameraForTab = function (tab, prev) {
const Cm = GE.cam; const wide = APP.isWide();
if (tab === 'now') {
Cm.flyTo(APP.heroCam(), { duration: prev ? 1.2 : 0 });
Cm.autoSpin = 0.3;
} else if (tab === 'globe') {
Cm.autoSpin = 0;
if (prev === 'now' || prev === 'you' || prev === 'threads' || !prev) Cm.flyTo({ range: Math.min(2.7, Math.max(Cm.range, 0.5)), shiftY: wide ? 0 : 0.06, shiftX: 0, tilt: 0 }, { duration: 1.0 });
} else if (tab === 'you' || tab === 'threads') { Cm.autoSpin = 0.3; if (!wide) Cm.flyTo(APP.heroCam(), { duration: 0.9 }); }
};
APP.moveLens = function () {
const on = $(`.tab[data-tab="${APP.tab}"]`); const lens = $('#tablens'); if (!on || !lens) return;
lens.style.transform = `translateX(${on.offsetLeft}px)`; lens.style.width = on.offsetWidth + 'px';
};
APP.minimize = function (on) {
if (APP._mini === on) return; APP._mini = on;
$('#tabwrap').classList.toggle('mini', on);
$('#accHost').classList.toggle('hidden', on || !APP.showAcc());
setTimeout(APP.moveLens, 460); requestAnimationFrame(APP.moveLens);
};
APP.showAcc = () => !!APP.following && (APP.tab === 'now' || APP.tab === 'threads' || APP.tab === 'you' || (APP.isWide() && APP.tab === 'globe')) && !APP.mode;
APP.updateChrome = function () {
const ride = APP.mode === 'ride', sit = APP.mode === 'situation';
$('#tabwrap').classList.toggle('hidden', ride || (sit && !APP.isWide()) || APP.intro);
$('#accHost').classList.toggle('hidden', !APP.showAcc() || APP._mini || APP.intro);
$('#back').classList.toggle('on', sit);
$('#sitbtns').classList.toggle('on', sit);
// globe ribbon/dock offsets when the sheet is open (phone)
const gb = $('#gbottom');
gb.style.opacity = sit && !APP.isWide() ? '0' : ''; gb.style.pointerEvents = sit && !APP.isWide() ? 'none' : '';
};
/* ---------- accessory ---------- */
APP.renderAccessory = function () {
const s = GE.SIT.byId[APP.following]; if (!s) return;
const html = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span><svg class="spk" width="54" height="22" viewBox="0 0 54 22">${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}</svg><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
$('#acc').innerHTML = html;
$('#accMini').innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span>`;
};
function sparkPath(data, w, h, col, sw = 1.6) {
const mn = Math.min(...data), mx = Math.max(...data), n = data.length;
const pts = data.map((v, i) => [(i / (n - 1)) * (w - 4) + 2, h - 3 - ((v - mn) / (mx - mn || 1)) * (h - 6)]);
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join('');
const l = pts[pts.length - 1];
return `<path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${l[0]}" cy="${l[1]}" r="2.4" fill="${col}"/>`;
}
GE.sparkPath = sparkPath;
/* ---------- overlay labels on the globe ---------- */
const LBL = { els: new Map() };
APP.updateLabels = function (view) {
const host = $('#labels'); if (!host) return;
const want = [];
const showPins = (APP.tab === 'globe' || APP.isWide()) && !APP.mode && !APP.intro;
if (showPins) for (const s of GE.SIT.list) {
if (s.dyn) continue;
const short = s.short || s.title.split(/ [—–-] /)[0].slice(0, 28);
want.push({ k: 's:' + s.id, lat: s.lat, lon: s.lon, alt: 0.2, pr: s.watchers, html: `<i style="--c:${GE.FAM[s.fam].color}"></i>${esc(short)}`, w: short.length * 6.6 + 32, cls: 'glass', onclick: () => GE.sheet.open(s.id, { fromGlobe: true }) });
}
if (GE.world.sel && GE.world.sel.v && APP.mode !== 'ride') want.push({ k: 'sel', v: GE.world.sel.v, pr: 1e9, html: `<b>${esc(GE.world.sel.title)}</b>`, w: 90, cls: 'plain tag' });
for (const [k, l] of GE.extraLabels || []) want.push({ k, pr: 1e8, w: 120, ...l });
want.sort((a, b) => b.pr - a.pr);
const placed = []; const shown = new Set();
const cap = view.range > 1.2 ? 7 : view.range > 0.3 ? 12 : 40;
for (const l of want) {
const p = l.v || geo.vec(l.lat, l.lon, l.alt || 0);
const s = GE.world.project(view, p);
if (!s || !s.vis || shown.size >= cap) continue;
const tag = l.cls && l.cls.includes('tag');
const box = tag ? [s.x + 10, s.y - 13, s.x + 10 + l.w, s.y + 13] : [s.x - l.w / 2, s.y - 36, s.x + l.w / 2, s.y - 8];
if (placed.some((b) => !(box[2] < b[0] - 4 || box[0] > b[2] + 4 || box[3] < b[1] - 3 || box[1] > b[3] + 3))) continue;
placed.push(box); shown.add(l.k);
let el = LBL.els.get(l.k);
if (!el) { el = h('button', { class: 'lbl ' + (l.cls || '') }); el.innerHTML = l.html; if (l.onclick) el.addEventListener('click', l.onclick); else el.style.pointerEvents = 'none'; host.append(el); LBL.els.set(l.k, el); }
else if (el._html !== l.html) el.innerHTML = l.html;
el._html = l.html; el.style.opacity = '1';
el.style.transform = `translate3d(${s.x.toFixed(1)}px, ${(s.y - (tag ? 0 : 8)).toFixed(1)}px, 0) translate(${tag ? '10px, -50%' : '-50%, -100%'})`;
}
for (const [k, el] of LBL.els) if (!shown.has(k)) { if (!want.some((w) => w.k === k)) { el.remove(); LBL.els.delete(k); } else el.style.opacity = '0'; }
};
APP.heroCam = function () {
const W = GE.world; const sun = W.sun;
if (!APP._heroLon || Math.abs((APP._heroT || 0) - Date.now()) > 600000) {
let best = sun.lon + 70, bs = -1;
for (const off of [-80, -65, 65, 80]) { const lon0 = sun.lon + off; let sum = 0; for (let la = -50; la <= 65; la += 7) for (let lo = -60; lo <= 60; lo += 7) { const L = lon0 + lo; const v = geo.vec(la, L); if (v3.dot(v, sun.dir) < -0.05 && geo.dist(24, lon0, la, L) < 1.15) sum += W.lightsAt(la, ((L + 540) % 360) - 180); } if (sum > bs) { bs = sum; best = lon0; } }
APP._heroLon = ((best + 540) % 360) - 180; APP._heroT = Date.now();
}
const sl = APP._heroLon - 66; return APP.isWide() ? { lat: 20, lon: sl + 62, range: 2.6, heading: 0, tilt: 0, shiftY: 0, shiftX: 0 } : { lat: 24, lon: sl + 66, range: 2.85, heading: 0, tilt: 0, shiftY: 0.5, shiftX: 0 }; };
GE.extraLabels = new Map();
/* ---------- wiring ---------- */
APP.initShell = function () {
$$('.tab').forEach((b) => b.addEventListener('click', () => APP.setTab(b.dataset.tab)));
const openFollowed = () => GE.sheet.open(APP.following);
$('#acc').addEventListener('click', openFollowed); $('#accMini').addEventListener('click', openFollowed);
$('#btnSearch').addEventListener('click', () => GE.gui.openSearch());
// minimize tab bar on scroll (feed-like views)
for (const id of ['#v-now', '#v-threads', '#v-you']) {
const v = $(id); let last = 0;
v.addEventListener('scroll', () => {
const y = v.scrollTop; const dy = y - last; last = y;
if (y < 40) APP.minimize(false); else if (dy > 4) APP.minimize(true); else if (dy < -6) APP.minimize(false);
const nb = v.querySelector('.navbar'); if (nb) nb.classList.toggle('on', y > (id === '#v-now' ? (APP.isWide() ? 90 : 250) : 60));
clearTimeout(v._st); v._st = setTimeout(() => APP.minimize(false), 1400);
bus.emit('scroll', { id, y });
}, { passive: true });
}
addEventListener('resize', () => { APP.moveLens(); const w = APP.isWide(); if (w !== APP.wide) { APP.wide = w; APP.setTab(APP.tab, { force: true }); } });
APP.wide = APP.isWide();
setInterval(APP.renderAccessory, 1000); APP.renderAccessory();
requestAnimationFrame(APP.moveLens);
};
+162
View File
@@ -0,0 +1,162 @@
/* Now: RedNote-style two-column masonry of living situation cards. Thumbnails are rendered from the
same WebGL globe in an offscreen pass (texture-crop fallback first, so there is never a blank). */
const NOWV = (GE.now = { filter: 'all' });
const TH = (GE.thumbs = { q: [], chains: new Map(), done: new Map() });
/* ---------- thumbnail renderer ---------- */
Cam.viewFrom = function (spec, w, h) {
const save = { lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftX: Cam.shiftX, shiftY: Cam.shiftY, rig: Cam.rig };
Object.assign(Cam, { shiftX: 0, shiftY: 0, heading: 0, tilt: 0 }, spec); Cam.rig = null;
const v = Cam.view(w, h); Object.assign(Cam, save); return v;
};
TH.chainFor = function (w, h) { const k = w + 'x' + h; if (!TH.chains.has(k)) TH.chains.set(k, { chain: GL.chain(w, h), out: GL.target(w, h, { hdr: false }) }); return TH.chains.get(k); };
TH.render = function (spec, w, h, o = {}) { return TH.renderView(Cam.viewFrom(spec, w, h), w, h, o); };
TH.renderView = function (view, w, h, o = {}) {
const gl = GL.gl; const { chain, out } = TH.chainFor(w, h);
const W = GE.world;
const at = o.at ?? null;
const t = at != null ? (at - NOW) / 1000 : W.t;
const sun = at != null ? astro.sun(at) : W.sun;
const offsetH = at != null ? (at - Date.now()) / 3600000 : W.offsetH;
const saved = W.layers.map((l) => l.on);
if (o.layers) for (const id of o.layers) if (W.layerById[id]) W.layerById[id].on = true;
W.render(view, chain, out.fb, { t: o.t ?? t, sun: o.sun ?? sun, offsetH: o.offsetH ?? offsetH, simMs: o.simMs ?? at ?? W.simMs, lens: o.lens || 'cinematic', outW: w, outH: h, vpW: w, vpH: h, pxScale: o.px ?? 1.35, aurora: o.aurora, exposure: o.exposure ?? 1.08, grain: false, focus: o.focus, extraSprites: o.extraSprites, extraLines: o.extraLines, steps: 10, lineK: 1.25, cloudK: o.cloudK });
W.layers.forEach((l, i) => (l.on = saved[i]));
const px = new Uint8Array(w * h * 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const cv = o.canvas || document.createElement('canvas'); cv.width = w; cv.height = h;
const cx = cv.getContext('2d'); const id = cx.createImageData(w, h);
for (let y = 0; y < h; y++) id.data.set(px.subarray((h - 1 - y) * w * 4, (h - y) * w * 4), y * w * 4);
cx.putImageData(id, 0, 0);
return cv;
};
TH.situationSpec = (s) => s.thumbCam || GE.SIT.cameraFor(s);
TH.renderSituation = function (s, canvas) {
const w = canvas.width, hh = canvas.height;
const extras = GE.extrasFor ? GE.extrasFor(s.id) : null;
return TH.render(TH.situationSpec(s), w, hh, { at: s.time?.at ?? null, layers: s.layers, canvas, aurora: s.auroraK, extraSprites: extras?.sprites, extraLines: extras?.lines, focus: s.kind === 'recon' ? null : null });
};
TH.enqueue = function (fn) { TH.q.push(fn); };
TH.step = function (budgetMs = 10) {
const t0 = performance.now(); let n = 0;
while (TH.q.length && (n === 0 || performance.now() - t0 < budgetMs)) { const fn = TH.q.shift(); try { fn(); } catch (e) { console.warn('thumb', e); } n++; }
};
// texture-crop fallback, drawn instantly
TH.fallback = function (s, canvas) {
const W = GE.world; const cx = canvas.getContext('2d'); const w = canvas.width, hh = canvas.height;
const spec = TH.situationSpec(s) || { lat: s.lat, lon: s.lon, range: 0.3 };
const spanDeg = clamp(spec.range * 70, 1.2, 120);
const img = W.img?.day; if (!img) { cx.fillStyle = '#0b1220'; cx.fillRect(0, 0, w, hh); return; }
const sx = ((spec.lon + 180) / 360) * img.width, sy = ((90 - spec.lat) / 180) * img.height;
const sw = (spanDeg / 360) * img.width, sh = sw * (hh / w);
cx.drawImage(img, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh);
const el = astro.elev(s.time?.at ?? Date.now(), s.lat, s.lon);
if (el < 4) { cx.globalAlpha = clamp((4 - el) / 12, 0, 0.92); cx.drawImage(W.img.night, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh); cx.globalAlpha = 1; }
const g = cx.createRadialGradient(w / 2, hh / 2, Math.min(w, hh) * 0.2, w / 2, hh / 2, Math.max(w, hh) * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.6)'); cx.fillStyle = g; cx.fillRect(0, 0, w, hh);
};
/* ---------- feed ---------- */
const famIcon = (f) => GE.FAM[f].icon;
function cardHTML(s) {
const c = GE.FAM[s.fam].color;
const [m, ml] = s.metric();
const badge = s.d4 ? `<span class="badge d4">4D</span><span class="badge replay">Thread</span>` : s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : `<span class="badge replay">${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}</span>`;
const right = s.thread ? `<span class="stack">${['tamang', 'hydro', 'kora', 'geo'].map((k) => `<span class="avatar sm" style="--h:${GE.PEOPLE[k].hue}">${GE.PEOPLE[k].h[0].toUpperCase()}</span>`).join('')}</span>` : `<span class="r">${ic('eye', 'xs')}<span class="num">${fmt.k(s.watchers)}</span></span>`;
return `<div class="thumb" style="aspect-ratio:${1}/${s.aspect}"><canvas width="360" height="${Math.round(360 * s.aspect)}"></canvas>
<div class="tl">${badge}</div><div class="tr" style="--c:${c}">${ic(famIcon(s.fam), 'sm')}</div>
<div class="bl"><div class="metric num" data-m>${m}</div><div class="mlabel num" data-ml>${ml}</div></div></div>
<div class="meta"><div class="ttl">${esc(s.title)}</div><div class="src"><span class="l"><span style="width:6px;height:6px;border-radius:50%;background:${c};flex:none"></span><span data-age>${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}</span></span>${right}</div>
${s.ride ? `<button class="cta" data-ride>${ic('helicopter', 'sm')}Jump into the chopper</button>` : ''}</div>`;
}
NOWV.render = function () {
const v = $('#v-now');
const now = new Date();
const kick = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' });
v.innerHTML = `<div class="navbar">Now</div>
<div class="now-hero">
<div class="lt"><div class="kicker"><span class="dot pulse"></span><span>${kick}</span>·<span class="num" id="nowClock">${fmt.hm(Date.now())} UTC</span></div>
<div class="row"><h1>Now</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div></div>
<div class="livebar"><span><b class="num" id="cSit">${GE.SIT.list.length}</b> situations</span><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num" id="cSat">${fmt.n(GE.SATS.length)}</b> satellites</span></div>
</div>
<div class="now-body"><div class="chips" id="nowChips">
${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => `<button class="chip ${NOWV.filter === k ? 'on' : ''}" data-f="${k}">${l}</button>`).join('')}
</div><div id="nowSecs"></div></div>`;
$('#nowMe').addEventListener('click', () => APP.setTab('you'));
$$('#nowChips .chip').forEach((b) => b.addEventListener('click', () => { NOWV.filter = b.dataset.f; $$('#nowChips .chip').forEach((x) => x.classList.toggle('on', x === b)); NOWV.renderSections(); haptic(4); }));
NOWV.renderSections();
};
NOWV.renderSections = function () {
const host = $('#nowSecs'); host.innerHTML = '';
for (const sec of GE.SIT.sections) {
if (NOWV.filter !== 'all' && NOWV.filter !== sec.id) continue;
const items = GE.SIT.list.filter((s) => s.section === sec.id);
const hd = h('div', { class: 'sec-h' }, h('h2', null, sec.title), h('span', null, sec.sub));
const grid = h('div', { class: 'masonry' }); const cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols);
const hs = [0, 0];
for (const s of items) {
const el = h('article', { class: 'card', 'data-id': s.id, tabindex: 0, role: 'button', 'aria-label': s.title }); el.innerHTML = cardHTML(s);
const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += s.aspect + 0.62;
el.addEventListener('click', (e) => { if (e.target.closest('[data-ride]')) { e.stopPropagation(); NOWV.open(s, el, { ride: true }); return; } NOWV.open(s, el); });
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); NOWV.open(s, el); } });
const cv = el.querySelector('canvas');
const cached = TH.done.get(s.id);
if (cached) cv.getContext('2d').drawImage(cached, 0, 0);
else { TH.fallback(s, cv); TH.enqueue(() => { const c2 = document.createElement('canvas'); c2.width = cv.width; c2.height = cv.height; TH.renderSituation(s, c2); TH.done.set(s.id, c2); $$(`.card[data-id="${s.id}"] canvas, .tcard[data-id="${s.id}"] canvas`).forEach((x) => { const xc = x.getContext('2d'); xc.drawImage(c2, 0, 0, x.width, x.height); }); }); }
}
host.append(hd, grid);
}
};
NOWV.tick = function () {
const W = GE.world;
const c = W.counts; const air = (c.flights || 0) + (c.military || 0) + (c.ga || 0) + GE.HELI_CIRCUITS.length;
const a = $('#cAir'); if (a) a.textContent = fmt.n(air);
const sh = $('#cShip'); if (sh) sh.textContent = fmt.n(GE.SHIPS.length);
const ck = $('#nowClock'); if (ck) ck.textContent = fmt.hm(Date.now()) + ' UTC';
for (const el of $$('.card[data-id]')) {
const s = GE.SIT.byId[el.dataset.id]; if (!s) continue;
const [m, ml] = s.metric(); const me = el.querySelector('[data-m]'), mle = el.querySelector('[data-ml]');
if (me && me.innerHTML !== m) { me.innerHTML = m; } if (mle && mle.innerHTML !== ml) mle.innerHTML = ml;
const ag = el.querySelector('[data-age]'); if (ag) ag.textContent = `${s.source} · ${fmt.ago(Date.now() - s.updatedAt)}`;
}
};
/* ---------- hero morph: card → live globe ---------- */
NOWV.open = function (s, cardEl, o = {}) {
const wide = APP.isWide();
const thumb = cardEl?.querySelector('.thumb canvas');
if (wide || !thumb || prefersReduced()) { GE.sheet.open(s.id, { ...o, fade: !wide }); return; }
const r = thumb.getBoundingClientRect(); const app = $('#app').getBoundingClientRect();
const M = $('#morph'); M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
// target: the visible world above the sheet's medium detent
const tw = app.width, th = app.height * 0.57;
Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', borderRadius: '18px', opacity: '1', transition: 'none' });
const sx = r.width / tw, sy = r.height / th;
M.style.transformOrigin = '0 0';
M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${sx}, ${sy})`;
M.classList.add('on'); NOWV._morphCard = cardEl;
cardEl.style.visibility = 'hidden';
GE.sheet.open(s.id, { ...o, morph: true });
requestAnimationFrame(() => requestAnimationFrame(() => {
const k = GE.HASH && GE.HASH.slow ? +GE.HASH.slow : 1; M.style.transition = `transform ${0.62 * k}s cubic-bezier(.2,.9,.2,1), border-radius ${0.62 * k}s ease, opacity ${0.5 * k}s ease ${0.34 * k}s`;
M.style.transform = 'translate(0,0) scale(1,1)'; M.style.borderRadius = '0px'; M.style.opacity = '0';
setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; }, 900 * k);
}));
};
NOWV.foldBack = function (id) {
const cardEl = $(`#v-now .card[data-id="${id}"]`); const M = $('#morph');
if (!cardEl || prefersReduced() || APP.isWide()) return false;
const thumb = cardEl.querySelector('.thumb canvas'); const app = $('#app').getBoundingClientRect();
M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
const tw = app.width, th = app.height * 0.57;
Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', transformOrigin: '0 0', transition: 'none', transform: 'none', borderRadius: '0px', opacity: '0' });
M.classList.add('on'); cardEl.style.visibility = 'hidden';
requestAnimationFrame(() => {
const r = thumb.getBoundingClientRect();
M.style.transition = 'transform .55s cubic-bezier(.3,.8,.2,1), border-radius .55s ease, opacity .18s ease';
M.style.opacity = '1'; M.style.borderRadius = '18px';
M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${r.width / tw}, ${r.height / th})`;
setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; cardEl.classList.add('flash'); setTimeout(() => cardEl.classList.remove('flash'), 1200); }, 600);
});
return true;
};
+196
View File
@@ -0,0 +1,196 @@
/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always
wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with
uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */
const SH = (GE.sheet = { id: null, detent: 'medium' });
const sheetEl = () => $('#sheet');
SH.heights = function () {
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
return { peek: 112 + sab, medium: Math.round(H * 0.45), large: Math.round(H * 0.92), H };
};
SH.setDetent = function (d, animate = true) {
SH.detent = d; const el = sheetEl(); if (APP.isWide()) { el.classList.add('wopen'); return; }
const hs = SH.heights(); const full = el.offsetHeight || hs.H; const vis = hs[d];
el.classList.toggle('drag', !animate);
el.style.transform = `translateY(${Math.max(0, full - vis)}px)`;
const sc = $('#sheetC'); sc.style.overflowY = d === 'large' ? 'auto' : 'hidden'; sc.style.touchAction = d === 'large' ? 'pan-y' : 'none';
if (d !== 'large') sc.scrollTop = 0;
SH.targetShift = d === 'large' ? 0.75 : clamp(vis / hs.H, 0, 0.8);
};
SH.open = function (id, o = {}) {
const s = GE.SIT.byId[id]; if (!s) return;
const W = GE.world;
SH.id = id; SH.sit = s;
if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); }
APP.setMode('situation');
// layers & time window
SH._savedLayers = SH._savedLayers || W.layers.map((l) => l.on);
for (const lid of s.layers || []) if (W.layerById[lid]) W.layerById[lid].on = true;
bus.emit('layers', W.layers);
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
W.auroraK = s.auroraK ?? 0.85;
GE.setExtras && GE.setExtras(s.id);
// camera
const cam = GE.SIT.cameraFor(s);
const hs = SH.heights();
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
SH.targetShift = shiftY;
Cam.autoSpin = 0;
if (o.morph) { Cam.set({ ...cam, shiftY, shiftX: 0 }); Cam.flyTo({ range: cam.range * 0.82, heading: cam.heading + 0.12 }, { duration: 2.6 }); }
else Cam.flyTo({ ...cam, shiftY, shiftX: 0 });
W.focus = s.kind === 'recon' ? null : { lat: s.lat, lon: s.lon, k: 0.35 };
SH.render(s);
const el = sheetEl();
if (APP.isWide()) { el.classList.add('wopen'); }
else { el.classList.add('drag'); el.style.transform = 'translateY(100%)'; requestAnimationFrame(() => requestAnimationFrame(() => SH.setDetent(o.detent || 'medium'))); }
if (o.ride && s.ride) setTimeout(() => GE.ride.start({ kind: 'heli', id: 0 }), 650);
APP.updateChrome();
};
SH.close = function (silent) {
if (!SH.id) return; const id = SH.id; const W = GE.world;
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null);
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
W.setWindow(null); W.setTime(null); W.auroraK = 0.85;
APP.setMode('');
if (silent) return;
const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`);
if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); }
};
/* drag */
SH.initDrag = function () {
const el = sheetEl(); let st = null;
const begin = (e) => {
if (APP.isWide()) return;
const onGrab = e.target.closest('#grab'); const sc = $('#sheetC');
if (!onGrab && SH.detent === 'large') return;
if (e.target.closest('button, a, input, textarea, .evid, .multi')) return;
st = { y: e.clientY, y0: e.clientY, t: performance.now(), base: new DOMMatrix(getComputedStyle(el).transform).m42, moved: 0, id: e.pointerId, samples: [] };
el.setPointerCapture?.(e.pointerId);
};
el.addEventListener('pointerdown', begin);
el.addEventListener('pointermove', (e) => {
if (!st || e.pointerId !== st.id) return;
const dy = e.clientY - st.y0; st.moved = Math.max(st.moved, Math.abs(dy));
if (st.moved < 4) return;
el.classList.add('drag');
const hs = SH.heights(); const full = el.offsetHeight; const minY = full - hs.large, maxY = full - hs.peek + 40;
let y = st.base + dy; if (y < minY) y = minY - Math.sqrt(minY - y) * 3; if (y > maxY) y = maxY + Math.sqrt(y - maxY) * 3;
el.style.transform = `translateY(${y}px)`;
st.samples.push({ t: performance.now(), y: e.clientY }); if (st.samples.length > 6) st.samples.shift();
SH.targetShift = clamp((full - y) / hs.H, 0, 0.8);
});
const end = (e) => {
if (!st || e.pointerId !== st.id) return;
const s0 = st.samples[0], s1 = st.samples[st.samples.length - 1];
const v = s0 && s1 && s1.t > s0.t ? (s1.y - s0.y) / (s1.t - s0.t) : 0; // px/ms
const hs = SH.heights(); const full = el.offsetHeight;
const cur = new DOMMatrix(getComputedStyle(el).transform).m42; const visNow = full - cur + -v * 220;
if (st.moved >= 4) {
if (visNow < hs.peek * 0.6 && v > 0.6) { SH.close(); st = null; return; }
const ds = ['peek', 'medium', 'large']; let best = 'medium', bd = 1e9; for (const d of ds) { const dd = Math.abs(hs[d] - visNow); if (dd < bd) { bd = dd; best = d; } }
SH.setDetent(best); haptic(5);
} else if (e.target.closest('#grab')) { SH.setDetent(SH.detent === 'peek' ? 'medium' : SH.detent === 'medium' ? 'large' : 'medium'); }
st = null;
};
el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
bus.on('globe-touch', () => { if (SH.id && SH.detent !== 'peek' && !APP.isWide()) SH.setDetent('peek'); });
$('#back').addEventListener('click', () => { if (APP.mode === 'situation') SH.close(); });
$('#sitFollow').addEventListener('click', () => { if (!SH.id) return; APP.following = SH.id === 'cyclone' || !SH.sit.accessory ? 'cyclone' : SH.id; APP.toast(`${ic('bell', 'sm')} Following · alerts within your radius`); haptic(10); });
$('#sitShare').addEventListener('click', () => APP.toast(`${ic('link', 'sm')} Link copied · opens at this place &amp; time`));
};
/* ---------- content ---------- */
function sparkSVG(sr, w) {
const hgt = 46, pad = 3; const d = sr.data; const n = d.length;
const band = sr.band || 0;
let mn = Math.min(...d) - band, mx = Math.max(...d) + band; if (mx - mn < 1e-6) { mx += 1; mn -= 1; }
const X = (i) => pad + (i / (n - 1)) * (w - pad * 2 - 34), Y = (v) => pad + 6 + (1 - (v - mn) / (mx - mn)) * (hgt - pad * 2 - 12);
const split = sr.split ?? n - 1;
const path = (a, b) => d.slice(a, b + 1).map((v, k) => `${k ? 'L' : 'M'}${X(a + k).toFixed(1)} ${Y(v).toFixed(1)}`).join('');
let s = '';
if (band) { const up = d.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v + band * (i > split ? 1 + (i - split) * 0.35 : 1)).toFixed(1)}`).join(''); const lo = d.map((v, i) => [X(i), Y(v - band * (i > split ? 1 + (i - split) * 0.35 : 1))]).reverse().map(([x, y]) => `L${x.toFixed(1)} ${y.toFixed(1)}`).join(''); s += `<path d="${up}${lo}Z" fill="rgba(124,196,255,.12)"/>`; }
s += `<path d="${path(0, split)}" fill="none" stroke="#DCE6F5" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
if (split < n - 1) s += `<path d="${path(split, n - 1)}" fill="none" stroke="#8AB4F8" stroke-width="1.5" stroke-dasharray="3 3"/>`;
const iMax = d.indexOf(Math.max(...d)), iMin = d.indexOf(Math.min(...d));
const f = sr.fmt || ((v) => v);
s += `<circle cx="${X(iMax)}" cy="${Y(d[iMax])}" r="2.2" fill="#FF8A6B"/><text x="${X(iMax)}" y="${Y(d[iMax]) - 5}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMax])}</text>`;
if (iMin !== iMax) s += `<circle cx="${X(iMin)}" cy="${Y(d[iMin])}" r="2.2" fill="#7CC4FF"/><text x="${X(iMin)}" y="${Y(d[iMin]) + 13}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMin])}</text>`;
const ln = split; s += `<circle cx="${X(ln)}" cy="${Y(d[ln])}" r="3" fill="#fff"/><text x="${X(ln) + 6}" y="${Y(d[ln]) + 3.5}" font-size="10.5" font-weight="700" fill="#fff" font-family="var(--font)">${f(d[ln])}</text>`;
if (split < n - 1) s += `<line x1="${X(split)}" x2="${X(split)}" y1="2" y2="${hgt - 2}" stroke="rgba(255,255,255,.18)" stroke-dasharray="2 2"/><text x="${X(split) + 3}" y="${hgt - 1}" font-size="8.5" fill="#8AB4F8" font-family="var(--font)">forecast</text>`;
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
}
GE.sparkSVG = sparkSVG;
function barsSVG(items, w) {
const hgt = items.length * 22; let s = '';
items.forEach(([k, v], i) => { const y = i * 22; s += `<text x="0" y="${y + 14}" font-size="12" fill="rgba(232,238,248,.75)" font-family="var(--font)">${k}</text><rect x="92" y="${y + 6}" width="${(w - 132) * v}" height="10" rx="3" fill="rgba(124,196,255,.55)"/><text x="${92 + (w - 132) * v + 6}" y="${y + 15}" font-size="11" fill="rgba(232,238,248,.55)" font-family="var(--font)">${Math.round(v * 100)}</text>`; });
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
}
SH.render = function (s) {
const c = GE.FAM[s.fam].color; const el = $('#sheetC');
const ms = s.metrics();
const live = s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : s.d4 ? `<span class="badge d4">4D</span>` : `<span class="badge replay">Replay</span>`;
const acts = [
`<button class="act pri" data-a="follow">${ic('bellplus')}Follow</button>`,
s.ride ? `<button class="act gold" data-a="ride">${ic('helicopter')}Ride</button>` : `<button class="act" data-a="replay">${ic('clock')}Replay</button>`,
`<button class="act" data-a="eye">${ic('video')}Nearest eye</button>`,
`<button class="act" data-a="thread">${ic('threads')}Thread</button>`,
].join('');
const b = s.brief || {};
el.innerHTML = `
<div class="sh-hd"><div class="t"><div style="display:flex;gap:6px;align-items:center"><span class="badge fam" style="--c:${c}"><i></i>${GE.FAM[s.fam].name}</span>${live}<span style="font-size:12px;color:var(--text3)" class="num" data-sage>${fmt.ago(Date.now() - s.updatedAt)}</span></div>
<h2>${esc(s.title)}</h2><div class="pl">${esc(s.place)} · <span class="mono">${fmt.ll(s.lat, s.lon, 2)}</span></div></div></div>
<div class="metrics" id="shMetrics">${ms.map((m) => `<div class="metric"><div class="v">${m.v}<small>${m.u}</small></div><div class="l">${m.l}</div><div class="s">${m.s}</div></div>`).join('')}</div>
<div class="acts sh-acts">${acts}</div>
<div class="brief">
${b.lede ? `<p>${esc(b.lede)}</p>` : ''}
${b.series?.length ? `<h3>Signals</h3><div id="shSeries"></div>` : ''}
${b.multiples ? `<h3>${esc(b.multiples.label)}</h3><div class="multi" id="shMulti"></div>` : ''}
<h3>Evidence</h3><div class="evid" id="shEvid"></div>
<h3>Sources</h3><div class="srcs">${(b.sources || []).map(([n, a, st]) => `<div class="r"><span class="st ${st === 'stale' ? 'stale' : ''}"></span><b style="font-weight:500">${esc(n)}</b><span>${esc(a)}</span></div>`).join('')}
<div class="r"><span class="st"></span><b style="font-weight:500">Sun, terminator &amp; time</b><span>computed</span></div></div>
${s.thread ? `<div style="margin-top:18px"><button class="btn pri" data-a="thread" style="width:100%">${ic('threads')}Open the reconstruction</button></div>` : ''}
<p style="margin-top:20px;font-size:11.5px;color:var(--text3)">Concept · illustrative data. People, handles, posts and claims shown here are fictional.</p>
</div>`;
const w = Math.max(240, (APP.isWide() ? $('#sheet').clientWidth : $('#app').clientWidth) - 36);
if (b.series?.length) $('#shSeries').innerHTML = b.series.map((sr) => `<div class="sline"><span class="lab">${esc(sr.label)}</span><span class="val">${(sr.fmt || ((v) => v))(sr.data[sr.split ?? sr.data.length - 1])} ${esc(sr.unit)}</span>${sparkSVG(sr, w)}<div class="src"><span>${esc(sr.src)}</span><span>${sr.band ? '± band = uncertainty' : ''}</span></div></div>`).join('');
if (b.multiples) {
const mh = $('#shMulti');
if (b.multiples.kind === 'bars') { mh.style.gridTemplateColumns = '1fr'; mh.innerHTML = barsSVG(b.multiples.items, w); }
else for (const it of b.multiples.items) {
const fig = h('figure'); const cv = h('canvas', { width: 180, height: 180 }); fig.append(cv, h('figcaption', { html: it.cap })); mh.append(fig);
const spec = it.cam || { lat: s.lat, lon: s.lon, range: 0.2, heading: 0, tilt: 0.2 };
TH.fallback(s, cv);
TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 180, 180, { at: Date.now() + it.h * 3600000, layers: s.layers, canvas: cv, px: 1.0 }); });
}
}
// evidence strip — each tile is rendered from the globe near the place (signed captures, illustrative)
const ev = $('#shEvid'); const people = ['tamang', 'hydro', 'sky', 'ais', 'geo', 'crowd'];
const cam0 = GE.SIT.cameraFor(s) || { lat: s.lat, lon: s.lon, range: 0.01 };
for (let i = 0; i < 5; i++) {
const p = GE.PEOPLE[people[(i + s.id.length) % people.length]];
const cv = h('canvas', { width: 232, height: 168 }); const im = h('div', { class: 'im' }); im.append(cv); cv.style.cssText = 'width:100%;height:100%;display:block';
const near = cam0.range < 0.02;
const spec = { lat: cam0.lat + (hash01(i + 3) - 0.5) * (near ? 0.01 : 0.6), lon: cam0.lon + (hash01(i + 9) - 0.5) * (near ? 0.012 : 0.6), range: near ? cam0.range * (0.35 + i * 0.12) : 0.004 + i * 0.003, heading: i * 1.3 + 0.4, tilt: 1.25 };
TH.fallback({ ...s, thumbCam: spec }, cv);
TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 232, 168, { at: s.time?.at ?? null, layers: s.layers, canvas: cv, px: 1.0, extraSprites: GE.world.activeExtras?.sprites, extraLines: GE.world.activeExtras?.lines }); });
im.insertAdjacentHTML('beforeend', `<svg class="seal"><use href="#i-seal"/></svg>`);
const card = h('div', { class: 'ev' }, im, h('div', { class: 'cap', html: `<b>@${p.h}</b>${['Signed · IMU ✓ GPS ±5 m', 'Signed · horizon ✓', 'Signed · 12 s clip', 'Signed · heading ✓', 'Signed · audio sync'][i]}<br><span class="num">${fmt.ago((i + 1) * 1000 * 60 * (7 + i * 13))}</span>` }));
ev.append(card);
}
el.querySelectorAll('[data-a]').forEach((btn) => btn.addEventListener('click', () => SH.action(btn.dataset.a, s)));
};
SH.tick = function () {
if (!SH.id) return; const s = SH.sit; const ms = s.metrics(); const host = $('#shMetrics'); if (!host) return;
host.querySelectorAll('.metric').forEach((m, i) => { const v = m.querySelector('.v'); const html = `${ms[i].v}<small>${ms[i].u}</small>`; if (v.innerHTML !== html) v.innerHTML = html; });
const a = $('[data-sage]'); if (a) a.textContent = fmt.ago(Date.now() - s.updatedAt);
};
SH.action = function (a, s) {
haptic(6);
if (a === 'follow') { APP.following = s.accessory ? s.id : 'cyclone'; APP.toast(`${ic('bell', 'sm')} Following ${esc(s.title.split(' — ')[0])}`); }
if (a === 'ride') GE.ride.start({ kind: 'heli', id: 0 });
if (a === 'replay') { GE.world.setTime(Date.now() - 6 * 3600000); APP.toast(`${ic('clock', 'sm')} Replaying the last 6 h — drag the ribbon`); SH.setDetent('peek'); }
if (a === 'eye') { const off = geo.dest(s.lat, s.lon, 0.7, 0.8); Cam.flyTo({ lat: off[0], lon: off[1], range: 0.0006, tilt: 1.1 }); SH.setDetent('peek'); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera 0.8 km · illustrative`); }
if (a === 'thread') { if (s.thread) { SH.close(true); APP.setTab('threads'); GE.threads.openWs(s.thread); } else APP.toast(`${ic('threads', 'sm')} Thread started · invite people to add observations`); }
};
+221
View File
@@ -0,0 +1,221 @@
/* Globe chrome: lens dock (8 families), Control-Center layer grid (long-press = solo), visual lenses,
haptic time ribbon (−72 h ← NOW → +120 h) with event pips, pick card, search. */
const GUI = (GE.gui = {});
const FAMS = Object.keys(GE.FAM);
const LENSES = [
['cinematic', 'Cinematic', 'linear-gradient(135deg,#0B2A55,#1E6FB8 45%,#F2B36B 80%,#2a1a10)'],
['clear', 'Clear', 'linear-gradient(135deg,#3B6EA8,#8FB9E3 50%,#C9D9A4)'],
['night', 'Night', 'radial-gradient(circle at 60% 40%,#FFC46B 0,#7A4A12 8%,#0B1426 30%,#04070D)'],
['thermal', 'Thermal', 'linear-gradient(90deg,#08001A,#5A0F8C 30%,#E3263F 55%,#FF9F00 78%,#FFFBD6)'],
['nvg', 'NVG', 'radial-gradient(circle,#B8FFB0 0,#2EA043 35%,#05200C 75%)'],
];
GUI.famCount = (f) => GE.world.layers.filter((l) => l.fam === f && l.on).reduce((s, l) => s + (l.count || 0), 0);
/* ---------- dock ---------- */
GUI.renderDock = function () {
const d = $('#dock'); const W = GE.world;
d.innerHTML = FAMS.map((f) => { const F = GE.FAM[f]; const on = W.layers.some((l) => l.fam === f && l.on); return `<button class="fam glass ${on ? '' : 'off'} ${GUI.solo === f ? 'solo' : ''}" data-f="${f}" style="--c:${F.color}">${ic(F.icon)}${F.name}<span class="n num">${fmt.k(GUI.famCount(f))}</span></button>`; }).join('');
$$('#dock .fam').forEach((b) => {
let tm = null, long = false;
b.addEventListener('pointerdown', () => { long = false; tm = setTimeout(() => { long = true; GUI.soloFam(b.dataset.f); }, 520); });
const clr = () => clearTimeout(tm);
b.addEventListener('pointerup', clr); b.addEventListener('pointerleave', clr); b.addEventListener('pointercancel', clr);
b.addEventListener('click', () => { if (long) return; GUI.openCC(b.dataset.f); });
b.addEventListener('contextmenu', (e) => e.preventDefault());
});
};
GUI.soloFam = function (f) {
const W = GE.world;
if (GUI.solo === f) { if (GUI._preSolo) W.layers.forEach((l, i) => (l.on = GUI._preSolo[i])); GUI.solo = null; APP.toast(`Restored all families`); }
else { if (!GUI.solo) GUI._preSolo = W.layers.map((l) => l.on); GUI.solo = f; W.layers.forEach((l) => (l.on = l.fam === f ? (l.on || ['flights', 'ais', 'sats', 'quakes', 'fires', 'cyclones', 'cctv', 'holo', 'venues', 'clouds', 'radar', 'gnss'].includes(l.id)) : l.id === 'clouds')); APP.toast(`<span style="color:${GE.FAM[f].color}">●</span> Soloed ${GE.FAM[f].name} · long-press to restore`); }
haptic([8, 30, 8]); bus.emit('layers', W.layers);
};
/* ---------- Control Center grid ---------- */
GUI.ccHTML = function (inPane) {
const W = GE.world;
const lens = `<div class="lenses">${LENSES.map(([id, n, bg]) => `<button class="lens ${W.lens === id ? 'on' : ''}" data-lens="${id}"><span class="sw" style="background:${bg}"></span>${n}</button>`).join('')}</div>`;
const fams = FAMS.map((f) => { const F = GE.FAM[f]; const ls = W.layers.filter((l) => l.fam === f);
return `<section class="ccfam" id="ccf-${f}" style="--c:${F.color}"><div class="fh" data-fh="${f}"><i></i>${F.name}<span style="color:var(--text4);font-weight:600">${ls.length}</span><button class="solo" data-solo="${f}">${GUI.solo === f ? 'Restore' : 'Solo'}</button></div><div class="ccgrid">${ls.map((l) => `<button class="tile ${l.on ? 'on' : ''}" data-l="${l.id}"><span class="ti">${ic(l.icon || F.icon)}</span><span><div class="tn">${esc(l.name)}</div><div class="tc"><span class="st ${l.id === 'models' || l.id === 'imagery' ? 'stale' : ''}"></span><span class="num">${l.count ? fmt.n(l.count) : '—'}</span>${l.id === 'models' || l.id === 'imagery' ? ' · 3 h' : ''}</div></span></button>`).join('')}</div></section>`; }).join('');
return `<div class="cch"><div><h2>Layers</h2><p>${W.layers.length} live layers in 8 families · long-press a family to solo</p></div>${inPane ? '' : `<button class="circ glass" id="ccX" aria-label="Close">${ic('x')}</button>`}</div><div class="stratum" style="margin-top:4px">Lens</div>${lens}${fams}`;
};
GUI.bindCC = function (root) {
const W = GE.world;
root.querySelectorAll('[data-l]').forEach((b) => b.addEventListener('click', () => { const l = W.layerById[b.dataset.l]; l.on = !l.on; b.classList.toggle('on', l.on); haptic(4); bus.emit('layers', W.layers); }));
root.querySelectorAll('[data-lens]').forEach((b) => b.addEventListener('click', () => { W.setLens(b.dataset.lens); root.querySelectorAll('[data-lens]').forEach((x) => x.classList.toggle('on', x === b)); haptic(6); }));
root.querySelectorAll('[data-solo]').forEach((b) => b.addEventListener('click', () => { GUI.soloFam(b.dataset.solo); GUI.refreshCC(); }));
root.querySelectorAll('[data-fh]').forEach((hd) => { let tm; hd.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; tm = setTimeout(() => { GUI.soloFam(hd.dataset.fh); GUI.refreshCC(); }, 520); }); const c = () => clearTimeout(tm); hd.addEventListener('pointerup', c); hd.addEventListener('pointerleave', c); });
root.querySelector('#ccX')?.addEventListener('click', GUI.closeCC);
};
GUI.openCC = function (fam) {
if (APP.isWide()) { if (APP.tab !== 'globe') APP.setTab('globe'); GUI.renderLayersPane(); const t = $('#ccf-' + fam); if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
const cc = $('#cc'); cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.classList.add('on');
if (fam) requestAnimationFrame(() => { const t = $('#ccf-' + fam); if (t) cc.scrollTop = t.offsetTop - 60; });
haptic(6);
};
GUI.refreshCC = function () { const cc = $('#cc'); if (cc.classList.contains('on')) { const st = cc.scrollTop; cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.scrollTop = st; } if (APP.isWide() && APP.tab === 'globe') GUI.renderLayersPane(); };
GUI.closeCC = () => $('#cc').classList.remove('on');
GUI.renderLayersPane = function () { const v = $('#v-layers'); const st = v.scrollTop; v.innerHTML = `<div style="padding:calc(var(--sat) + 16px) 16px 140px">${GUI.ccHTML(true)}</div>`; GUI.bindCC(v); v.scrollTop = st; };
/* ---------- ribbon ---------- */
const RB = { span: 30, vel: 0, drag: null };
GUI.initRibbon = function () {
const el = $('#ribbon'), cv = el.querySelector('canvas');
const W = GE.world;
const hpp = () => RB.span / Math.max(1, el.clientWidth);
el.addEventListener('pointerdown', (e) => { if (e.target.closest('#rbNow')) return; el.setPointerCapture(e.pointerId); RB.drag = { x: e.clientX, t: performance.now(), lastHour: Math.floor(W.offsetH) }; RB.vel = 0; Cam.lastInput = performance.now(); });
el.addEventListener('pointermove', (e) => {
if (!RB.drag) return; const now = performance.now(); const dx = e.clientX - RB.drag.x; const dt = Math.max(1, now - RB.drag.t);
const speed = Math.abs(dx) / dt; // px/ms
const k = clamp(0.22 + speed * 1.1, 0.22, 2.6); // slow → minutes, fast → hours
const dh = -dx * hpp() * k;
GUI.scrubTo(W.offsetH + dh);
RB.vel = dh / dt * 16; RB.drag.x = e.clientX; RB.drag.t = now;
const hr = Math.floor(W.offsetH); if (hr !== RB.drag.lastHour) { RB.drag.lastHour = hr; haptic(3); }
});
const up = () => { RB.drag = null; };
el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
$('#rbNow').addEventListener('click', () => GUI.snapNow());
GUI.drawRibbon();
};
GUI.scrubTo = function (h) {
const W = GE.world; const win = W.getWindow(); const nowMs = Date.now();
const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000;
W.setTime(nowMs + clamp(h, lo, hi) * 3600000);
};
GUI.snapNow = function () {
const W = GE.world; const win = W.getWindow();
if (W.window) { GUI.scrubTo(((win.start + win.end) / 2 - Date.now()) / 3600000); return; }
const from = W.offsetH; const t0 = performance.now(); haptic(12);
const anim = () => { const u = clamp((performance.now() - t0) / 600, 0, 1); const e = ease.outBack(u); const v = from * (1 - e); if (u >= 1) { W.setTime(null); return; } W.setTime(Date.now() + v * 3600000); requestAnimationFrame(anim); };
anim();
};
GUI.ribbonFrame = function () {
if (!RB.drag && Math.abs(RB.vel) > 1e-4) { GUI.scrubTo(GE.world.offsetH + RB.vel); RB.vel *= 0.92; }
GUI.drawRibbon();
};
GUI.drawRibbon = function () {
const el = $('#ribbon'); if (!el || !el.offsetWidth) return; const cv = el.querySelector('canvas');
const W = GE.world; const dpr = Math.min(devicePixelRatio || 1, 2);
const w = el.clientWidth, hh = el.clientHeight;
if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(hh * dpr); }
const cx = cv.getContext('2d'); cx.setTransform(dpr, 0, 0, dpr, 0, 0); cx.clearRect(0, 0, w, hh);
const win = W.getWindow(); const nowMs = Date.now();
RB.span = W.window ? Math.max(4, (win.end - win.start) / 3600000 * 0.7) : 30;
const cur = W.offsetH; const pxh = w / RB.span; const X = (hr) => w / 2 + (hr - cur) * pxh;
const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000;
// past / forecast regions
const xNow = X(0);
if (!W.window) {
cx.fillStyle = 'rgba(138,180,248,.08)'; cx.fillRect(Math.max(0, xNow), 0, Math.max(0, w - Math.max(0, xNow)), hh);
cx.save(); cx.beginPath(); cx.rect(Math.max(0, xNow), 0, w, hh); cx.clip(); cx.strokeStyle = 'rgba(138,180,248,.10)'; cx.lineWidth = 1; for (let x = Math.floor(xNow) - hh; x < w; x += 7) { cx.beginPath(); cx.moveTo(x, hh); cx.lineTo(x + hh, 0); cx.stroke(); } cx.restore();
}
// out-of-window shade
cx.fillStyle = 'rgba(0,0,0,.35)'; if (X(lo) > 0) cx.fillRect(0, 0, X(lo), hh); if (X(hi) < w) cx.fillRect(X(hi), 0, w - X(hi), hh);
// ticks
const h0 = Math.floor(cur - RB.span / 2) - 1, h1 = Math.ceil(cur + RB.span / 2) + 1;
cx.font = '600 10px ' + getComputedStyle(document.body).fontFamily; cx.textAlign = 'center';
for (let k = h0; k <= h1; k++) {
const x = X(k); const abs = new Date(nowMs + k * 3600000); const hrUTC = abs.getUTCHours();
const major = hrUTC % 6 === 0, day = hrUTC === 0;
cx.strokeStyle = day ? 'rgba(255,255,255,.55)' : major ? 'rgba(255,255,255,.32)' : 'rgba(255,255,255,.14)';
cx.lineWidth = day ? 1.4 : 1;
const th = day ? 18 : major ? 12 : 6; cx.beginPath(); cx.moveTo(x, hh - 6); cx.lineTo(x, hh - 6 - th); cx.stroke();
if (major && pxh * 6 > 34) { cx.fillStyle = day ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)'; cx.fillText(day ? abs.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' }) : String(hrUTC).padStart(2, '0'), x, hh - 26 - (day ? 2 : 0)); }
// minute ticks when zoomed (slow scrub)
}
// NOW marker
if (!W.window && xNow > -2 && xNow < w + 2) { cx.fillStyle = '#FF453A'; cx.fillRect(xNow - 1, 4, 2, hh - 8); }
// event pips
const pips = GUI.pips();
for (const p of pips) { const x = X(p.h); if (x < -6 || x > w + 6) continue; cx.fillStyle = p.c; cx.beginPath(); cx.arc(x, hh - 6, 3.2, 0, TAU); cx.fill(); cx.shadowColor = p.c; cx.shadowBlur = 8; cx.beginPath(); cx.arc(x, hh - 6, 2, 0, TAU); cx.fill(); cx.shadowBlur = 0; if (Math.abs(x - w / 2) < 60) { cx.fillStyle = 'rgba(255,255,255,.7)'; cx.font = '600 9.5px ' + getComputedStyle(document.body).fontFamily; cx.fillText(p.l, x, hh - 14); } }
// readout
const simMs = nowMs + cur * 3600000;
$('#rbTime').textContent = fmt.utc(simMs);
$('#rbRel').textContent = W.window ? '' : W.live ? '' : fmt.rel(cur);
el.classList.toggle('future', cur > 0.02 && !W.window);
el.classList.toggle('breath', W.live && !prefersReduced());
const nb = $('#rbNow'); nb.classList.toggle('live', W.live); nb.innerHTML = W.window ? `${ic('clock', 'xs')}EVENT` : W.live ? '<span class="dot pulse"></span>LIVE' : 'NOW';
const stt = $('#statusTxt'); if (stt) { stt.textContent = W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'; $('#statusTime').textContent = fmt.utc(simMs, false) + ' UTC'; $('#status .dot').style.background = W.live ? '' : cur > 0 ? 'var(--wx)' : 'var(--gold)'; }
};
GUI.pips = function () {
const nowMs = Date.now(); const W = GE.world;
if (W.window && GE.threads?.ws?.beats) return GE.threads.ws.beats.map((b) => ({ h: (b.at - nowMs) / 3600000, c: '#E9C46A', l: b.t }));
return [{ h: GE.BIGQ.t / 3600, c: '#FF6A3D', l: 'M6.4' }, { h: 2.24, c: '#C9A7FF', l: 'Launch' }, { h: 40, c: '#FF6A3D', l: 'Landfall' }, { h: (GE.SIT.byId.aurora.time.at - nowMs) / 3600000, c: '#5CF2A0', l: 'Aurora' }, { h: -30, c: '#FF6A3D', l: 'Fire run' }, { h: (SIT._iss?.start - nowMs) / 3600000 || 3, c: '#C9A7FF', l: 'ISS' }];
};
/* ---------- pick card ---------- */
GUI.showPick = function (p) {
const el = $('#pick'); const W = GE.world;
if (!p) { el.classList.remove('on'); W.sel = null; W.selFlight = -1; W.selSat = -1; W.selRoute = null; return; }
const famOf = { flight: 'sky', heli: 'sky', ship: 'sea', sat: 'orbit', quake: 'haz', holo: 'eyes', situation: 'haz' }[p.kind] || 'sky';
const icn = { flight: 'airplane', heli: 'helicopter', ship: 'ferry', sat: 'iss', quake: 'wave', holo: 'seal', situation: 'radiowaves' }[p.kind];
W.sel = { ...p, v: p.kind === 'flight' ? v3.scale(geo.vec(p.lat, p.lon), 1 + (p.altKm * W.altX) / EARTH_KM) : geo.vec(p.lat, p.lon, p.altKm || 0.1) };
W.selFlight = p.kind === 'flight' ? p.id : -1; W.selSat = p.kind === 'sat' ? p.id : -1;
if (p.kind === 'flight') { const m = p.meta; W.selRoute = (() => { const a = m.from, b = m.to; const pts = []; for (let i = 0; i <= 64; i++) { const t = i / 64; pts.push(v3.scale(geo.slerp(a.v, b.v, t), 1 + 0.002)); } return pts; })(); } else W.selRoute = null;
let kv = '';
if (p.kind === 'flight') kv = `<div><b>${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)}</b>ft</div><div><b>${Math.round(p.meta.f.speed / 1.852)}</b>kt</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div><div><b>${Math.round(p.meta.prog * 100)}%</b>route</div>`;
else if (p.kind === 'ship') kv = `<div><b>${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
else if (p.kind === 'heli') kv = `<div><b>${Math.round(p.altKm * 3281)}</b>ft AGL</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div>`;
else kv = `<div><b class="mono">${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
const canRide = p.kind === 'flight' || p.kind === 'heli';
el.style.setProperty('--c', GE.FAM[famOf].color);
el.innerHTML = `<div class="hd"><span class="ico">${ic(icn)}</span><div class="tt"><b>${esc(p.title)}</b><span>${esc(p.subtitle || '')}</span></div><button class="x" aria-label="Close">${ic('x', 'sm')}</button></div><div class="kv">${kv}</div>
<div class="acts"><button class="act pri" data-p="follow">${ic('bellplus')}Follow</button><button class="act ${canRide ? 'gold' : ''}" data-p="ride" ${canRide ? '' : 'disabled style="opacity:.4"'}>${ic(p.kind === 'heli' ? 'helicopter' : 'airplane')}Ride</button><button class="act" data-p="eye">${ic('video')}Nearest eye</button><button class="act" data-p="thread">${ic('threads')}Thread</button></div>`;
el.classList.add('on');
el.querySelector('.x').addEventListener('click', () => GUI.showPick(null));
el.querySelectorAll('[data-p]').forEach((b) => b.addEventListener('click', () => {
const a = b.dataset.p; haptic(6);
if (a === 'ride' && canRide) { GUI.showPick(null); GE.ride.start(p); }
if (a === 'follow') APP.toast(`${ic('bell', 'sm')} Following ${esc(p.title)}`);
if (a === 'eye') { Cam.flyTo({ lat: p.lat, lon: p.lon, range: Math.min(Cam.range, 0.004), tilt: 1.0 }); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera ${(0.6 + hash01(p.id || 1) * 3).toFixed(1)} km · illustrative`); }
if (a === 'thread') APP.toast(`${ic('threads', 'sm')} Thread started for ${esc(p.title)}`);
}));
};
bus.on('tap', ({ x, y }) => {
if (APP.mode === 'ride' || APP.tab === 'capture') return;
if (APP.tab !== 'globe' && !APP.isWide()) return;
const v = GE.mainView; if (!v) return;
const p = GE.world.pick(v, x, y, 28);
if (p && p.kind === 'situation') { GE.sheet.open(p.id, { fromGlobe: true }); return; }
GUI.showPick(p); if (p) haptic(5);
});
/* ---------- search ---------- */
GUI.openSearch = function () {
const el = $('#search');
el.innerHTML = `<div class="res" id="sres"></div><div class="bar"><div class="field glass">${ic('search')}<input id="sq" placeholder="Places, situations, layers, callsigns" autocomplete="off" enterkeyhint="search"></div><button class="circ glass" id="sx" style="width:52px;height:52px">${ic('x')}</button></div>`;
el.classList.add('on');
const q = $('#sq'); const run = () => GUI.searchResults(q.value.trim());
q.addEventListener('input', run); run(); setTimeout(() => q.focus(), 60);
$('#sx').addEventListener('click', () => el.classList.remove('on'));
};
GUI.searchResults = function (q) {
const res = $('#sres'); const ql = q.toLowerCase(); const W = GE.world;
const groups = [];
const sits = GE.SIT.list.filter((s) => !ql || (s.title + s.place).toLowerCase().includes(ql)).slice(0, ql ? 6 : 4);
groups.push(['Situations', sits.map((s) => ({ c: GE.FAM[s.fam].color, i: GE.FAM[s.fam].icon, t: s.title, s: s.place, go: () => { $('#search').classList.remove('on'); GE.sheet.open(s.id, { fromGlobe: true }); } }))]);
const places = [...GE.CITY_V.map((c) => ({ ...c, k: 'city' })), ...GE.AP.map((a) => ({ name: `${a.iata} · ${a.city}`, lat: a.lat, lon: a.lon, k: 'ap' }))].filter((p) => ql && p.name.toLowerCase().includes(ql)).slice(0, 5);
groups.push(['Places', places.map((p) => ({ c: 'var(--ground)', i: p.k === 'ap' ? 'airplane' : 'location', t: p.name, s: fmt.ll(p.lat, p.lon, 2), go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: p.k === 'ap' ? 0.006 : 0.012, tilt: 0.9, heading: 0.3 }); } }))]);
const lays = W.layers.filter((l) => ql && l.name.toLowerCase().includes(ql)).slice(0, 4);
groups.push(['Layers', lays.map((l) => ({ c: GE.FAM[l.fam].color, i: l.icon, t: l.name, s: `${GE.FAM[l.fam].name} · ${l.on ? 'on' : 'off'} · ${fmt.n(l.count)}`, go: () => { l.on = true; bus.emit('layers'); $('#search').classList.remove('on'); GUI.openCC(l.fam); } }))]);
if (ql.length >= 2) {
const fl = []; for (let i = 0; i < GE.FLIGHTS.length && fl.length < 4; i++) { const f = GE.FLIGHTS[i]; if (f.cs.toLowerCase().includes(ql)) { const p = GE.flightAt(f, W.t); if (p) fl.push({ f, i, p }); } }
groups.push(['Contacts', fl.map(({ f, i, p }) => ({ c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${p.from.iata} → ${p.to.iata}`, go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: 0.05, tilt: 0.6 }); GUI.showPick({ kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog } }); } }))]);
}
res.innerHTML = ''; const frag = document.createDocumentFragment();
for (const [title, items] of groups) { if (!items.length) continue; frag.append(h('div', { class: 'grpH' }, title)); for (const it of items) { const b = h('button', { class: 'it', html: `<span class="ri" style="--c:${it.c}">${ic(it.i, 'sm')}</span><span style="text-align:left;min-width:0"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.t)}</span><small>${esc(it.s)}</small></span>` }); b.addEventListener('click', it.go); frag.append(b); } }
if (!q) frag.prepend(h('div', { class: 'grpH', style: { marginTop: 0 } }, 'Try “typhoon”, “ATL”, “Kathmandu”, “fires”, “AXR”'));
res.append(frag);
};
GUI.init = function () {
GUI.renderDock(); GUI.initRibbon();
bus.on('layers', () => { GUI.renderDock(); GUI.refreshCCSoft(); });
GUI.refreshCCSoft = () => { const cc = $('#cc'); if (cc.classList.contains('on')) cc.querySelectorAll('[data-l]').forEach((b) => { const l = GE.world.layerById[b.dataset.l]; b.classList.toggle('on', l.on); const n = b.querySelector('.tc .num'); if (n) n.textContent = l.count ? fmt.n(l.count) : '—'; }); };
$('#btnLayers').addEventListener('click', () => GUI.openCC());
$('#btnLens').addEventListener('click', () => { const ids = LENSES.map((l) => l[0]); const i = (ids.indexOf(GE.world.lens) + 1) % ids.length; GE.world.setLens(ids[i]); APP.toast(`${ic('sparkles', 'sm')} ${LENSES[i][1]} lens`); $('#btnLens').classList.toggle('on', ids[i] !== 'cinematic'); });
$('#btnHome').addEventListener('click', () => { GUI.showPick(null); Cam.flyTo({ range: 2.6, tilt: 0, heading: 0, shiftY: APP.isWide() ? 0 : 0.06 }); });
$('#btnCompass').addEventListener('click', () => Cam.flyTo({ heading: 0, tilt: 0 }, { duration: 0.8 }));
$('#status').addEventListener('click', () => GUI.snapNow());
};
+93
View File
@@ -0,0 +1,93 @@
/* Ride: drop into a cockpit-follow camera on a helicopter circuit or an airliner, with a minimal HUD
and a social reaction layer (fictional handles) — the communication aspect of watching together. */
const RIDE = (GE.ride = { on: false });
const CHAT_HELI = [
['rotorwash_ret', 'I flew that exact circuit 40 times. Downwind is where your instructor goes quiet.'],
['kora_maps', 'Watching from Lisbon. The stagefields look like a chessboard from here'],
['skywatch_ana', 'Look at the spacing — four aircraft in the pattern and nobody bunching up'],
['rotorwash_ret', 'Turning base now. Watch the airspeed bleed off'],
['hydro_nerd', 'is this live?? the shadows match my window right now'],
['geodesy_dan', 'MLAT puts them at ±15 m here. Cool to see it smooth'],
['ais_owl', 'Came from the Suez queue thread. This is so much calmer'],
['rotorwash_ret', 'Short final. Hover check. Then do it again. And again.'],
];
const CHAT_JET = [
['skywatch_ana', 'Cruise is the best seat in the house. Look at the terminator out the left side'],
['kora_maps', 'You can see the cloud deck the forecast promised'],
['geodesy_dan', 'Ground speed is up — jet stream tailwind'],
['ais_owl', 'There’s a whole convoy of ships under us right now'],
];
RIDE.start = function (p) {
const W = GE.world;
if (APP.mode === 'situation') { GE.sheet.close(true); }
RIDE.p = p; RIDE.on = true; RIDE.yaw = 0; RIDE.pitch = 0; RIDE.zoom = 1;
if (APP.tab !== 'globe') { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.pane .view').forEach((v) => v.classList.remove('on')); }
APP.setMode('ride');
W.layerById.helis.on = true;
const isHeli = p.kind === 'heli' || p.kind === 'situation';
const hc = isHeli ? GE.HELI_CIRCUITS[p.id || 0] : null; const fl = !isHeli ? GE.FLIGHTS[p.id] : null;
RIDE.hc = hc; RIDE.fl = fl;
const state = () => {
const t = W.t;
if (hc) { const a = GE.heliAt(hc, t), b = GE.heliAt(hc, t + 1.5); const hd = geo.bearing(a.lat, a.lon, b.lat, b.lon); const c = GE.heliAt(hc, t - 1.5); const hd0 = geo.bearing(c.lat, c.lon, a.lat, a.lon); let turn = hd - hd0; if (turn > Math.PI) turn -= TAU; if (turn < -Math.PI) turn += TAU; return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: hd, roll: clamp(turn * 6, -0.45, 0.45), spd: a.spd, vs: (b.altKm - c.altKm) / 3 * 3281 * 60 }; }
const a = GE.flightAt(fl, t) || GE.flightAt(fl, t + 600); const b = GE.flightAt(fl, t + 4) || a;
return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: geo.bearing(a.lat, a.lon, b.lat, b.lon), roll: 0, spd: fl.speed / 1.852, vs: (b.altKm - a.altKm) / 4 * 3281 * 60 };
};
RIDE.state = state;
const rig = {
blendTime: 1.8,
pose() { const s = state(); RIDE.s = s; const pitch = (hc ? -0.16 : -0.05) + RIDE.pitch; return Cam.pose(s.lat, s.lon, s.alt + (hc ? 0.003 : 0.002), s.hdg + RIDE.yaw, pitch, s.roll * 0.6, (hc ? 64 : 58) * D2R / RIDE.zoom); },
look(dx, dy) { RIDE.yaw -= dx * 0.004; RIDE.pitch = clamp(RIDE.pitch - dy * 0.003, -0.9, 0.5); },
zoom(r) { RIDE.zoom = clamp(RIDE.zoom / r, 0.6, 3); },
};
Cam.setRig(rig);
RIDE.renderUI(isHeli);
RIDE.chatI = 0; clearInterval(RIDE._ci); RIDE._ci = setInterval(() => RIDE.nextChat(), 2600); setTimeout(() => RIDE.nextChat(), 900);
clearInterval(RIDE._ri); RIDE._ri = setInterval(() => { if (Math.random() < 0.7) RIDE.heart(); }, 700);
haptic([10, 40, 10]);
};
RIDE.renderUI = function (isHeli) {
const el = $('#ride'); const nm = isHeli ? RIDE.hc.cs : RIDE.fl.cs; const tp = isHeli ? `${RIDE.hc.type} · ${RIDE.hc.field || 'orbit'} circuit` : `${RIDE.fl.type} · ${GE.flightAt(RIDE.fl, GE.world.t)?.from.iata} → ${GE.flightAt(RIDE.fl, GE.world.t)?.to.iata}`;
el.innerHTML = `<div class="hud" id="hud"><svg id="hudSvg"></svg><div class="tape" style="left:14px" id="hSpd"></div><div class="tape" style="right:14px;text-align:right" id="hAlt"></div></div>
<div class="top"><button class="circ glass" id="rideX" aria-label="Exit ride">${ic('chevd')}</button><div class="who glass"><span class="badge live" style="background:rgba(255,69,58,.2)"><span class="dot pulse"></span>Ride</span><span style="min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis"><b>${esc(nm)}</b> · ${esc(tp)}</span><span style="margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--text2)">${ic('people', 'sm')}<span class="num" id="rideN">2,318</span></span></div></div>
<div class="chat" id="chat"></div><div class="react" id="react"></div>
<div class="compose"><div class="in glass"><input id="rideIn" placeholder="Say something to everyone riding…" enterkeyhint="send" maxlength="120"></div><button class="circ glass" id="rideHeart" aria-label="React" style="width:48px;height:48px;color:var(--live)">${ic('heartf')}</button></div>`;
el.classList.add('on');
$('#rideX').addEventListener('click', () => RIDE.exit());
$('#rideHeart').addEventListener('click', () => { for (let i = 0; i < 4; i++) setTimeout(RIDE.heart, i * 90); haptic(6); });
$('#rideIn').addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.value.trim()) { RIDE.bubble('you', e.target.value.trim(), true); e.target.value = ''; } });
};
RIDE.bubble = function (who, text, mine) {
const p = Object.values(GE.PEOPLE).find((x) => x.h === who) || GE.PEOPLE.you;
const c = $('#chat'); if (!c) return;
const b = h('div', { class: 'bub' }); b.innerHTML = `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span><div class="bx glass" ${mine ? 'style="background:rgba(124,196,255,.22)"' : ''}><b>@${esc(p.h)}${who === 'rotorwash_ret' ? ' · former instructor' : ''}</b>${esc(text)}</div>`;
c.append(b); while (c.children.length > 6) c.firstChild.remove();
};
RIDE.nextChat = function () { const L = RIDE.hc ? CHAT_HELI : CHAT_JET; const [w, t] = L[RIDE.chatI++ % L.length]; RIDE.bubble(w, t); const n = $('#rideN'); if (n) n.textContent = fmt.n(2318 + Math.round(Math.sin(Date.now() / 4000) * 40 + RIDE.chatI * 7)); };
RIDE.heart = function () { const r = $('#react'); if (!r || prefersReduced()) return; const s = h('span'); s.innerHTML = ic('heartf'); s.style.setProperty('--dx', (Math.random() * 40 - 20) + 'px'); s.style.left = (Math.random() * 16) + 'px'; s.style.color = ['#FF453A', '#FF9F0A', '#FFD166', '#7CC4FF'][Math.floor(Math.random() * 4)]; r.append(s); setTimeout(() => s.remove(), 2700); };
RIDE.frame = function (view) {
if (!RIDE.on || !RIDE.s) return; const s = RIDE.s; const svg = $('#hudSvg'); if (!svg) return;
const w = svg.clientWidth, hh = svg.clientHeight; const cx = w / 2, cy = hh * 0.42;
const hdg = (((s.hdg + RIDE.yaw) * R2D) % 360 + 360) % 360; const roll = -s.roll * 0.6 * R2D; const pitchPx = (RIDE.pitch + (RIDE.hc ? -0.16 : -0.05)) * R2D * 6;
let g = `<g transform="rotate(${roll.toFixed(2)} ${cx} ${cy})" stroke="rgba(190,255,200,.8)" fill="rgba(190,255,200,.85)" font-size="10" font-family="var(--mono)">`;
g += `<line x1="${cx - 150}" y1="${cy + pitchPx}" x2="${cx - 40}" y2="${cy + pitchPx}" stroke-width="1.5"/><line x1="${cx + 40}" y1="${cy + pitchPx}" x2="${cx + 150}" y2="${cy + pitchPx}" stroke-width="1.5"/>`;
for (const pd of [-10, -5, 5, 10]) { const y = cy + pitchPx - pd * 6; g += `<line x1="${cx - 50}" y1="${y}" x2="${cx - 26}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><line x1="${cx + 26}" y1="${y}" x2="${cx + 50}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><text x="${cx + 56}" y="${y + 3}" stroke="none">${pd}</text>`; }
g += `</g>`;
g += `<g stroke="rgba(190,255,200,.95)" fill="none" stroke-width="1.6"><circle cx="${cx}" cy="${cy}" r="5"/><line x1="${cx - 18}" y1="${cy}" x2="${cx - 5}" y2="${cy}"/><line x1="${cx + 5}" y1="${cy}" x2="${cx + 18}" y2="${cy}"/><line x1="${cx}" y1="${cy - 5}" x2="${cx}" y2="${cy - 12}"/></g>`;
// heading tape
const ty = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sat')) + 70;
g += `<g font-size="11" font-family="var(--mono)" fill="rgba(190,255,200,.85)" stroke="rgba(190,255,200,.6)" text-anchor="middle">`;
for (let d = Math.floor(hdg / 5) * 5 - 40; d <= hdg + 40; d += 5) { const x = cx + (d - hdg) * 4; const dd = ((d % 360) + 360) % 360; g += `<line x1="${x}" y1="${ty}" x2="${x}" y2="${ty + (dd % 10 === 0 ? 9 : 5)}"/>`; if (dd % 30 === 0) g += `<text x="${x}" y="${ty + 22}" stroke="none">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? String(dd / 10).padStart(2, '0')}</text>`; }
g += `<rect x="${cx - 24}" y="${ty - 22}" width="48" height="18" rx="4" fill="rgba(0,20,8,.35)"/><text x="${cx}" y="${ty - 9}" stroke="none" font-weight="700">${String(Math.round(hdg)).padStart(3, '0')}</text></g>`;
svg.innerHTML = g;
$('#hSpd').innerHTML = `${Math.round(s.spd)}<small>KT GS</small>`;
$('#hAlt').innerHTML = `${fmt.n(Math.round(s.alt * 3281 / 10) * 10)}<small>FT ${RIDE.hc ? 'AGL' : 'MSL'} · ${s.vs > 0 ? '+' : ''}${Math.round(s.vs / 50) * 50} FPM</small>`;
};
RIDE.exit = function (silent) {
if (!RIDE.on) return; RIDE.on = false; clearInterval(RIDE._ci); clearInterval(RIDE._ri);
$('#ride').classList.remove('on'); $('#ride').innerHTML = '';
const v = Cam.view(400, 800); const [la, lo] = geo.ll(v.eye);
Cam.setRig(null); Cam.set({ lat: la, lon: lo, range: Math.max(0.002, v3.len(v.eye) - 1 + 0.002), tilt: 0.9, heading: RIDE.s ? RIDE.s.hdg : 0, shiftY: 0.06 });
APP.setMode('');
if (!silent) Cam.flyTo({ range: 0.012, tilt: 0.8 });
};
+178
View File
@@ -0,0 +1,178 @@
/* Capture: Look (AR — the God's-eye view projected around you on a rendered "camera" scene with
aircraft/satellite callouts, compass tape, horizon; drag to look) and Witness (shutter → sealed
bundle: real SHA-256 over the rendered frame, real ECDSA P-256 via WebCrypto, simulated IMU/GPS,
hash chain, gold seal; then drop it as a hologram here). */
const CAP = (GE.capture = { mode: 'look', yaw: 0, pitch: 0.06, zoom: 1, on: false });
GE.captures = [];
const SPOT = { lat: 41.8662, lon: -87.6072, alt: 0.042, name: 'Chicago lakefront', hdg: -52 * D2R };
CAP.spot = SPOT;
/* ---------- device key (non-extractable, IndexedDB when available) ---------- */
const KEY = { pair: null, jwk: null, ready: null };
function idb() { return new Promise((res, rej) => { try { const r = indexedDB.open('ge-vision', 1); r.onupgradeneeded = () => r.result.createObjectStore('keys'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); } catch (e) { rej(e); } }); }
KEY.get = function () {
if (KEY.ready) return KEY.ready;
KEY.ready = (async () => {
if (!hasSubtle()) return null;
try { const db = await idb(); const got = await new Promise((res) => { const tx = db.transaction('keys', 'readonly'); const rq = tx.objectStore('keys').get('device'); rq.onsuccess = () => res(rq.result); rq.onerror = () => res(null); }); if (got && got.privateKey) { KEY.pair = got; } } catch {}
if (!KEY.pair) {
try {
KEY.pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
try { const db = await idb(); await new Promise((res) => { const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').put(KEY.pair, 'device'); tx.oncomplete = res; tx.onerror = res; }); } catch {}
} catch (e) { console.warn('keygen', e); return null; }
}
try { KEY.jwk = await crypto.subtle.exportKey('jwk', KEY.pair.publicKey); } catch {}
return KEY.pair;
})();
return KEY.ready;
};
const hexToBytes = (hx) => new Uint8Array(hx.match(/../g).map((b) => parseInt(b, 16)));
async function signHex(hashHex) { const k = await KEY.get(); if (!k) return null; const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, k.privateKey, hexToBytes(hashHex)); return fmt.hex(sig); }
async function verifyHex(hashHex, sigHex) { const k = await KEY.get(); if (!k || !sigHex) return false; try { return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, k.publicKey, hexToBytes(sigHex), hexToBytes(hashHex)); } catch { return false; } }
GE.crypto = { KEY, signHex, verifyHex };
/* ---------- simulated golden hour at the spot ---------- */
CAP.goldenMs = function () {
const base = Date.now(); let best = base, bd = 1e9;
for (let m = -24 * 60; m <= 24 * 60; m += 4) { const ms = base + m * 60000; const e = astro.elev(ms, SPOT.lat, SPOT.lon); const e2 = astro.elev(ms + 60000, SPOT.lat, SPOT.lon); if (e2 < e) { const d = Math.abs(e - 3.2); if (d < bd) { bd = d; best = ms; } } }
return best;
};
/* ---------- enter / exit ---------- */
CAP.enter = function () {
CAP.on = true; CAP.ms = CAP.goldenMs(); CAP.t = (CAP.ms - NOW) / 1000; CAP.sun = astro.sun(CAP.ms);
// face the sunset over the skyline
const sunAz = geo.bearing(SPOT.lat, SPOT.lon, CAP.sun.lat, CAP.sun.lon);
CAP.yaw = 0; SPOT.hdg = sunAz * 0.35 + (-52 * D2R) * 0.65;
GE.renderOverride = { t: CAP.t, sun: CAP.sun, simMs: CAP.ms, offsetH: (CAP.ms - Date.now()) / 3600000, exposure: 1.25, cloudK: 0.5 };
Cam.setRig({ blendTime: 0.01, pose: () => Cam.pose(SPOT.lat, SPOT.lon, SPOT.alt, SPOT.hdg + CAP.yaw, CAP.pitch, 0, 62 * D2R / CAP.zoom), look(dx, dy) { CAP.yaw -= dx * 0.0042 / CAP.zoom; CAP.pitch = clamp(CAP.pitch - dy * 0.0035 / CAP.zoom, -0.5, 1.2); }, zoom(r) { CAP.zoom = clamp(CAP.zoom / r, 0.6, 4); CAP.syncZoom(); } });
Cam.rigBlend = 1;
GE.scenes.ensure(GE.scenes.byId.chi);
CAP.render();
KEY.get();
};
CAP.exit = function () { if (!CAP.on) return; CAP.on = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); };
CAP.render = function () {
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1];
el.innerHTML = `<div class="cap-ui">
<svg class="compass-tape" id="ctape"></svg>
<div class="callouts" id="callouts"></div>
<div class="cap-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}</span></div>
<div class="sensors glass ${CAP.mode === 'witness' ? 'on' : ''}" id="sensors"></div>
<div class="cap-bottom">
<div class="zooms glass">${[0.5, 1, 3].map((z) => `<button data-z="${z}" class="${CAP.zoom === z ? 'on' : ''}">${z === 0.5 ? '.5' : z}×</button>`).join('')}</div>
<div class="dial" id="dial"><button data-m="look" class="${CAP.mode === 'look' ? 'on' : ''}">Look</button><button data-m="witness" class="${CAP.mode === 'witness' ? 'on' : ''}">Witness</button></div>
<div class="shutrow"><button class="lastcap" id="lastcap" aria-label="Last capture" style="${last ? `background-image:url(${last.thumb})` : ''}">${last ? '<svg class="seal"><use href="#i-seal"/></svg>' : ''}</button>
<button class="shutter ${CAP.mode === 'look' ? 'look' : ''}" id="shutter" aria-label="${CAP.mode === 'look' ? 'Switch to Witness' : 'Capture and sign'}"><i></i></button>
<button class="circ glass" id="capFlip" aria-label="Layers in view">${ic('layers')}</button></div>
</div></div>`;
el.querySelectorAll('[data-z]').forEach((b) => b.addEventListener('click', () => { CAP.zoom = +b.dataset.z; CAP.syncZoom(); haptic(4); }));
el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.mode = b.dataset.m; CAP.render(); haptic(6); }));
$('#shutter').addEventListener('click', () => { if (CAP.mode === 'look') { CAP.mode = 'witness'; CAP.render(); return; } CAP.shoot(); });
$('#lastcap').addEventListener('click', () => { if (GE.captures.length) GE.you.inspect(GE.captures.length - 1); });
$('#capFlip').addEventListener('click', () => APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`));
};
CAP.syncZoom = () => $$('#v-capture [data-z]').forEach((x) => x.classList.toggle('on', +x.dataset.z === CAP.zoom));
/* ---------- per-frame AR overlay ---------- */
CAP.frame = function (view) {
if (!CAP.on) return;
const W = GE.world; const t = CAP.t + (performance.now() / 1000 - (CAP._t0 || (CAP._t0 = performance.now() / 1000)));
const host = $('#callouts'); if (!host) return;
// compass tape
const svg = $('#ctape'); const w = svg.clientWidth; const hdg = (((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360;
const pxPerDeg = w / (view.fovY * R2D * view.aspect);
let g = `<g font-family="var(--font)" font-size="11" text-anchor="middle" fill="rgba(255,255,255,.85)" stroke="rgba(255,255,255,.55)">`;
for (let d = Math.floor((hdg - 60) / 5) * 5; d <= hdg + 60; d += 5) { const x = w / 2 + (d - hdg) * pxPerDeg; if (x < -10 || x > w + 10) continue; const dd = ((d % 360) + 360) % 360; g += `<line x1="${x}" x2="${x}" y1="2" y2="${dd % 15 === 0 ? 11 : 6}"/>`; if (dd % 15 === 0) g += `<text x="${x}" y="25" stroke="none" font-weight="${dd % 90 === 0 ? 800 : 500}" fill="${dd === 0 ? '#FF6A3D' : 'rgba(255,255,255,.8)'}">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? dd}</text>`; }
g += `<path d="M${w / 2 - 5} 0 L${w / 2 + 5} 0 L${w / 2} 6Z" fill="#FFD60A" stroke="none"/><text x="${w / 2}" y="38" font-size="10" stroke="none" fill="rgba(255,255,255,.6)">${Math.round(hdg)}° · pitch ${Math.round(CAP.pitch * R2D)}°</text></g>`;
svg.innerHTML = g;
// contacts
const want = [];
const sun = CAP.sun;
for (let i = 0; i < GE.FLIGHTS.length; i++) {
const f = GE.FLIGHTS[i]; const p = GE.flightAt(f, CAP.t); if (!p) continue;
const dkm = geo.dist(SPOT.lat, SPOT.lon, p.lat, p.lon) * EARTH_KM; if (dkm > 110) continue;
const v = v3.scale(p.v, 1 + p.altKm / EARTH_KM); want.push({ k: 'f' + i, v, c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)} ft · ${dkm.toFixed(0)} km`, d: dkm });
}
const obs = geo.vec(SPOT.lat, SPOT.lon); const up = v3.norm(obs);
if (!CAP._sats || Math.abs(CAP._satT - CAP.t) > 20) {
CAP._sats = []; CAP._satT = CAP.t;
for (let i = 0; i < GE.SATS.length; i += 3) { const s = GE.SATS[i]; if (s.kind > 1 && s.kind !== 6 && s.kind !== 5) continue; const p = GE.satAt(s, CAP.t, sun.gmst); const d = v3.sub(p, obs); const el = Math.asin(v3.dot(v3.norm(d), up)) * R2D; if (el > 12) CAP._sats.push({ s, el, km: v3.len(d) * EARTH_KM }); }
CAP._sats.sort((a, b) => a.km - b.km); CAP._sats = CAP._sats.slice(0, 3);
}
for (const o of CAP._sats) { const p = GE.satAt(o.s, CAP.t, sun.gmst); want.push({ k: 's' + o.s.name, v: p, c: 'var(--orbit)', i: 'satellite', t: o.s.name, s: `${Math.round(o.s.alt)} km up · ${Math.round(o.el)}° elev`, d: o.km }); }
const seen = new Set(); let n = 0;
for (const it of want.sort((a, b) => a.d - b.d)) {
const s = W.project(view, it.v); let el = host.querySelector(`[data-k="${it.k}"]`);
const onScreen = s && s.x > 8 && s.x < view.w - 8 && s.y > 60 && s.y < view.h - 230;
if (!onScreen || n >= 7) { if (el) el.remove(); continue; }
n++; seen.add(it.k);
if (!el) { el = h('div', { 'data-k': it.k }); el.innerHTML = `<div class="reticle" style="--c:${it.c}"></div><div class="callout glass" style="--c:${it.c}"><span class="ci">${ic(it.i, 'sm')}</span><span><b>${esc(it.t)}</b><span data-s></span></span></div>`; host.append(el); }
el.querySelector('[data-s]').textContent = it.s;
el.querySelector('.reticle').style.transform = `translate(${s.x}px, ${s.y}px)`;
el.querySelector('.callout').style.transform = `translate(${s.x + 24}px, ${s.y}px) translate(0, -50%)`;
}
host.querySelectorAll('[data-k]').forEach((e) => { if (!seen.has(e.dataset.k)) e.remove(); });
// witness sensors
if (CAP.mode === 'witness') {
const ss = $('#sensors'); const tt = performance.now() / 1000;
const imu = Array.from({ length: 40 }, (_, i) => Math.sin((tt * 3 + i * 0.4)) * 0.4 + Math.sin(tt * 7.3 + i) * 0.15);
ss.innerHTML = `<div style="display:flex;justify-content:space-between"><span>IMU 50 Hz</span><b>${(imu[39] * 0.12).toFixed(3)} g</b></div><svg viewBox="0 0 140 26">${GE.sparkPath(imu, 140, 26, '#5CF2A0', 1.3)}</svg><div style="display:flex;justify-content:space-between"><span>GPS</span><b>±4 m · ${SPOT.lat.toFixed(5)}, ${SPOT.lon.toFixed(5)}</b></div><div style="display:flex;justify-content:space-between"><span>Heading</span><b>${Math.round(hdg)}° true</b></div><div style="display:flex;justify-content:space-between;gap:10px"><span>Key</span><b>P-256 · ${KEY.pair ? 'device-bound' : '…'}</b></div>`;
}
};
/* ---------- shoot & seal ---------- */
CAP.shoot = async function () {
if (CAP.busy) return; CAP.busy = true; haptic([5, 20, 30]);
const flash = h('div', { style: { position: 'absolute', inset: 0, background: '#fff', zIndex: 72, opacity: 1, transition: 'opacity .45s ease' } }); $('#app').append(flash); requestAnimationFrame(() => (flash.style.opacity = 0)); setTimeout(() => flash.remove(), 500);
const start = Date.now();
// render the frame offscreen at 1200×900
const W0 = 1200, H0 = 900; const view = Cam.view(W0, H0);
const cv = GE.thumbs.renderView(view, W0, H0, { ...GE.renderOverride, px: 1.6 });
const blob = await new Promise((res) => cv.toBlob(res, 'image/jpeg', 0.9));
const bytes = new Uint8Array(await blob.arrayBuffer());
const thumb = (() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; c.getContext('2d').drawImage(cv, 0, 0, 320, 240); return c.toDataURL('image/jpeg', 0.8); })();
const hdgDeg = Math.round((((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360);
const imu = Array.from({ length: 150 }, (_, i) => ({ t: i * 20, a: +(hdgDeg + Math.sin(i / 9) * 0.6).toFixed(2), b: +(CAP.pitch * R2D + Math.sin(i / 7) * 0.3).toFixed(2), g: +(Math.sin(i / 5) * 0.4).toFixed(2), ax: +(Math.sin(i / 3) * 0.02).toFixed(3), ay: +(9.806 + Math.sin(i / 4) * 0.03).toFixed(3), az: +(Math.cos(i / 3) * 0.02).toFixed(3) }));
const session = { start, end: start + 3000, imu, gps: [0, 1, 2].map((k) => ({ t: start + k * 1000, lat: +(SPOT.lat + k * 1e-6).toFixed(6), lon: +SPOT.lon.toFixed(6), acc: 4, alt: 182 })), heading: hdgDeg };
const context = { device: 'Simulated camera (concept)', ua: navigator.userAgent.slice(0, 64), screen: `${screen.width}x${screen.height}@${Math.round((devicePixelRatio || 1) * 100) / 100}`, timezone: GE.ME.tz, networkTimeOffsetMs: 12 };
const prev = GE.captures.length ? GE.captures[GE.captures.length - 1].hash : '0'.repeat(64);
const b = { bytes, thumb, session, context, prev, place: SPOT.name, lat: SPOT.lat, lon: SPOT.lon, at: start };
CAP.ceremony(b, cv);
b.mediaHash = await sha256(bytes);
b.hash = await sha256(canon({ media: b.mediaHash, session, context, prev }));
b.sig = await signHex(b.hash);
b.jwk = KEY.jwk;
GE.captures.push(b);
CAP.ceremonyFill(b);
CAP.busy = false;
};
CAP.ceremony = function (b, cv) {
const ov = $('#sealOv');
ov.innerHTML = `<div class="bundle glass strong"><div class="ph" id="bph"><svg class="bigseal" id="bigseal" viewBox="0 0 24 24"><use href="#i-seal"/></svg></div>
<div class="steps">
<div class="step" data-s="0"><span class="ck">${ic('check', 'xs')}</span><div><b>Frame hashed</b> · SHA-256 of ${fmt.n(b.bytes.length)} JPEG bytes<div class="h" id="sh0">…</div></div></div>
<div class="step" data-s="1"><span class="ck">${ic('check', 'xs')}</span><div><b>Session sealed</b> · IMU 150 samples @ 50 Hz · GPS ±4 m · heading ${b.session.heading}°<svg viewBox="0 0 300 30" preserveAspectRatio="none">${GE.sparkPath(b.session.imu.map((x) => x.g + x.b * 0.02), 300, 30, '#5CF2A0', 1.2)}</svg></div></div>
<div class="step" data-s="2"><span class="ck">${ic('check', 'xs')}</span><div><b>Chained</b> to this device’s previous bundle<div class="h">prev ${b.prev.slice(0, 24)}…</div></div></div>
<div class="step" data-s="3"><span class="ck">${ic('check', 'xs')}</span><div><b>Bundle hash</b><div class="h" id="sh3">…</div></div></div>
<div class="step" data-s="4"><span class="ck">${ic('check', 'xs')}</span><div><b>Signed</b> · ECDSA P-256, key generated on device, non-extractable<div class="h" id="sh4">…</div></div></div>
</div>
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('cube', 'sm')}Drop as hologram here</button></div>
<div class="btnrow" id="bbtns2" style="opacity:.35;pointer-events:none"><button class="btn" id="bver">${ic('check', 'sm')}Verify</button><button class="btn" id="bdone">Done</button></div>
<p style="margin:0;font-size:11px;color:var(--text3);text-align:center">None of this proves truth. It raises the cost of lying.</p></div>`;
const ph = $('#bph'); const c2 = document.createElement('canvas'); c2.width = 600; c2.height = 450; c2.getContext('2d').drawImage(cv, 0, 0, 600, 450); ph.prepend(c2);
ov.classList.add('on');
};
CAP.ceremonyFill = async function (b) {
const step = (i) => new Promise((r) => setTimeout(() => { const s = $(`.step[data-s="${i}"]`); s && s.classList.add('on', 'done'); haptic(4); r(); }, prefersReduced() ? 30 : 380));
const stream = async (id, hex) => { const el = $('#' + id); if (!el) return; if (prefersReduced()) { el.textContent = hex; return; } for (let i = 0; i <= hex.length; i += 4) { el.textContent = hex.slice(0, i) + (i < hex.length ? '▍' : ''); await new Promise((r) => setTimeout(r, 8)); } };
$(`.step[data-s="0"]`).classList.add('on'); await stream('sh0', b.mediaHash); await step(0);
await step(1); await step(2);
$(`.step[data-s="3"]`).classList.add('on'); await stream('sh3', b.hash); await step(3);
$(`.step[data-s="4"]`).classList.add('on'); await stream('sh4', b.sig ? b.sig : 'WebCrypto unavailable here — bundle hashed but unsigned'); await step(4);
$('#bigseal').classList.add('on'); haptic([10, 40, 20]);
for (const id of ['bbtns', 'bbtns2']) { const e = $('#' + id); e.style.opacity = 1; e.style.pointerEvents = 'auto'; }
$('#bdrop').addEventListener('click', () => { GE.world.userHolos.push({ lat: b.lat, lon: b.lon, title: 'Your capture · ' + b.place, by: 'you', at: b.at }); b.dropped = true; $('#sealOv').classList.remove('on'); APP.toast(`${ic('cube', 'sm')} Hologram dropped · visible to people here`); CAP.render(); GE.you.render(); });
$('#bver').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.you.inspect(GE.captures.indexOf(b)); });
$('#bdone').addEventListener('click', () => { $('#sealOv').classList.remove('on'); CAP.render(); GE.you.render(); });
};
+288
View File
@@ -0,0 +1,288 @@
/* Threads: reconstructions people build together. Three strata — Observations (immutable, hashed,
sourced), Hypotheses (person/AI, confidence, citations, agree/dispute), Narrative (camera path through
space-time synced to the ribbon) — plus a real SHA-256 hash-chain ledger you can verify and tamper
with, and an AI refine composer whose output is recorded as a *proposed* hypothesis. */
const THR = (GE.threads = { ws: null });
const P = GE.PEOPLE;
const KT = GE.scenes.FLOOD.startMs; const HR_ = 3600000, MIN = 60000;
const KO = GE.scenes.KOSHI;
const THREADS = {
koshi: {
id: 'koshi', sit: 'koshi', title: 'Bhote Koshi outburst flood', place: 'Rasuwagadhi → Syaphrubesi, Nepal', date: '26 Aug 2026',
window: { start: KT - 1.5 * HR_, end: KT + 6 * HR_ }, people: ['hydro', 'kora', 'tamang', 'geo', 'sky', 'ais'],
cam: { lat: 28.205, lon: 85.352, range: 0.0042, heading: 0.32, tilt: 1.02 },
obs: [
{ id: 'O1', i: 'frame', f: 'infra', t: 'Optical satellite tile — before', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 24, 4, 52), meta: ['10 m', 'cloud 6%'] },
{ id: 'O2', i: 'frame', f: 'infra', t: 'Optical satellite tile — after: new scour along 9.4 km of channel', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 27, 4, 58), meta: ['10 m', 'cloud 12%'] },
{ id: 'O3', i: 'drop', f: 'haz', t: 'River gauge, Syaphrubesi: stage 3.1 → 7.8 m', src: 'Gauge feed (illustrative values)', at: KT + 1.75 * HR_, meta: ['15-min series', 'peak 09:30 UTC'] },
{ id: 'O4', i: 'wave', f: 'haz', t: 'No M ≥ 3 earthquake within 50 km in the prior 24 h', src: 'Seismic catalog', at: KT, meta: ['rules out seismic trigger'] },
{ id: 'O5', i: 'seal', f: 'eyes', t: 'Signed capture — water reaches the road at Timure', src: '@tamang_lens · illustrative', at: KT + 27 * MIN, meta: ['IMU ✓', 'GPS ±6 m', 'heading 214°'], lat: 28.2551, lon: 85.3712 },
{ id: 'O6', i: 'seal', f: 'eyes', t: 'Signed capture — debris against the Syaphrubesi bridge deck', src: '@hydro_nerd · illustrative', at: KT + 61 * MIN, meta: ['IMU ✓', 'GPS ±4 m', 'heading 352°'], lat: 28.1629, lon: 85.3402 },
{ id: 'O7', i: 'seal', f: 'eyes', t: 'Signed capture — fresh scour line at Rasuwagadhi', src: '@geodesy_dan · illustrative', at: KT + 21.5 * HR_, meta: ['IMU ✓', 'GPS ±5 m'], lat: 28.2802, lon: 85.3797 },
{ id: 'O8', i: 'helicopter', f: 'sky', t: 'ADS-B: rescue helicopter, 3 sorties along the valley', src: 'ADS-B/MLAT (illustrative track)', at: KT + 2.3 * HR_, meta: ['10:05–13:40 UTC'] },
{ id: 'O9', i: 'cloud', f: 'wx', t: 'Upstream rain gauge: 4 mm in 24 h', src: 'Met station (illustrative)', at: KT - 1 * HR_, meta: ['not a rain-driven flood'] },
{ id: 'O10', i: 'frame', f: 'infra', t: 'Glacial lake area change upstream: −0.42 km²', src: 'Derived from O1/O2-class imagery (illustrative)', at: Date.UTC(2026, 7, 27, 5, 0), meta: ['outside border, upstream'] },
],
hyps: [
{ id: 'H1', by: 'hydro', text: 'A glacial-lake outburst upstream — not rainfall — triggered the flood.', conf: 0.82, ci: [0.71, 0.9], cites: ['O4', 'O9', 'O10'], agree: 31, dispute: 2, status: 'supported' },
{ id: 'H2', by: 'kora', text: 'The crest passed Timure about 08:12 UTC and reached Syaphrubesi ~35 min later (≈ 5 m/s).', conf: 0.68, ci: [0.55, 0.79], cites: ['O3', 'O5', 'O6'], agree: 18, dispute: 4, status: 'supported' },
{ id: 'H3', by: 'tamang', text: 'Clip O5 was filmed from the north bank, about 140 m upstream of the Timure bridge.', conf: 0.74, ci: [0.6, 0.84], cites: ['O5'], agree: 12, dispute: 1, status: 'supported' },
{ id: 'H4', by: 'ai', ai: true, text: 'Peak stage at Syaphrubesi lagged the visible crest by ~20 min — consistent with debris damming at the bridge.', conf: 0.41, ci: [0.22, 0.6], cites: ['O3', 'O6'], agree: 3, dispute: 5, status: 'disputed', prompt: 'Why does the gauge peak late?' },
],
beats: [
{ at: KT, t: '07:45', text: 'Outburst upstream (inferred). The valley is quiet; the river is at its normal 3 m stage.', cites: ['H1', 'O4', 'O9'], cam: { lat: 28.262, lon: 85.372, range: 0.0032, heading: 3.4, tilt: 1.05 } },
{ at: KT + 20 * MIN, t: '08:05', text: 'The crest reaches Rasuwagadhi and the border bridge.', cites: ['H2', 'O7'], cam: { lat: 28.272, lon: 85.37, range: 0.0018, heading: 0.6, tilt: 1.12 } },
{ at: KT + 27 * MIN, t: '08:12', text: 'A witness on the north bank at Timure films water reaching the road.', cites: ['H3', 'O5'], cam: { lat: 28.247, lon: 85.364, range: 0.0016, heading: 0.35, tilt: 1.15 } },
{ at: KT + 61 * MIN, t: '08:46', text: 'Debris piles against the Syaphrubesi bridge deck.', cites: ['O6', 'H4'], cam: { lat: 28.152, lon: 85.336, range: 0.0016, heading: 0.2, tilt: 1.12 } },
{ at: KT + 105 * MIN, t: '09:30', text: 'Gauge peaks at 7.8 m — about 20 minutes after the visible crest.', cites: ['O3', 'H4'], cam: { lat: 28.15, lon: 85.33, range: 0.0026, heading: 0.9, tilt: 1.05 } },
{ at: KT + 140 * MIN, t: '10:05', text: 'First rescue sortie climbs the valley.', cites: ['O8'], cam: { lat: 28.19, lon: 85.345, range: 0.0045, heading: 0.3, tilt: 1.0 } },
],
suggestions: ['Align the witness clip to the corridor using the bridge as a landmark', 'Estimate crest speed between Timure and Syaphrubesi', 'What would falsify H1?'],
fallback: 'Using the Syaphrubesi bridge as a fixed landmark, clip [O6] was taken from the east abutment looking upstream; matching its horizon to the IMU pitch places the crest front 85 ± 20 m upstream of the bridge at 08:46 UTC. That timing agrees with the gauge [O3] if the crest travelled 4.6 ± 0.6 m/s from Timure [O5].\nConfidence: 0.63',
},
concert: {
id: 'concert', sit: 'concert', title: 'Stadium encore in 4D', place: 'Wembley, London', date: 'last night',
window: { start: Date.now() - 21 * HR_, end: Date.now() - 19.5 * HR_ }, people: ['crowd', 'sky', 'kora', 'geo'],
cam: { lat: 51.5546, lon: -0.2822, range: 0.00042, heading: 0.6, tilt: 1.1 },
obs: [
{ id: 'O1', i: 'seal', f: 'eyes', t: '2,311 signed phone captures from 86 seat blocks', src: 'Signed bundles (illustrative)', at: Date.now() - 20 * HR_, meta: ['IMU ✓ on 2,204', 'GPS indoor ±12 m'] },
{ id: 'O2', i: 'wave', f: 'eyes', t: 'Audio fingerprints align 1,980 clips to ±9 ms', src: 'Derived', at: Date.now() - 19.8 * HR_, meta: ['common track'] },
{ id: 'O3', i: 'video', f: 'eyes', t: 'Venue public camera, concourse north', src: 'Public feed (illustrative)', at: Date.now() - 20 * HR_, meta: ['720p'] },
{ id: 'O4', i: 'ticket', f: 'ground', t: 'Seat map & stage geometry', src: 'Published venue plan', at: Date.now() - 30 * 24 * HR_, meta: ['static'] },
],
hyps: [
{ id: 'H1', by: 'crowd', text: 'The encore began at 22:41:07 local, ±0.2 s.', conf: 0.9, ci: [0.84, 0.95], cites: ['O1', 'O2'], agree: 210, dispute: 3, status: 'supported' },
{ id: 'H2', by: 'geo', text: 'The pyro cue at 22:44 is visible from 86 of 92 seat blocks; 6 blocks are occluded by the roof cantilever.', conf: 0.71, ci: [0.6, 0.8], cites: ['O1', 'O4'], agree: 44, dispute: 6, status: 'supported' },
],
beats: [
{ at: Date.now() - 20 * HR_ - 10 * MIN, t: '22:41', text: 'Lights down. 2,311 phones come up together.', cites: ['H1'], cam: { lat: 51.5552, lon: -0.2815, range: 0.00035, heading: 0.9, tilt: 1.15 } },
{ at: Date.now() - 20 * HR_, t: '22:44', text: 'Pyro cue — seen from 86 blocks at once.', cites: ['H2'], cam: { lat: 51.5568, lon: -0.2778, range: 0.0003, heading: 3.6, tilt: 1.12 } },
{ at: Date.now() - 20 * HR_ + 12 * MIN, t: '22:56', text: 'Final chorus; the crowd reconstruction is densest here.', cites: ['O1', 'O2'], cam: { lat: 51.556, lon: -0.2796, range: 0.0006, heading: 2.2, tilt: 0.9 } },
],
suggestions: ['Which seat blocks saw the pyro cue?', 'Align the concourse camera to the crowd timeline'],
fallback: 'The concourse camera [O3] shows the crowd surge 2.1 s after the audio onset in [O2]; offsetting its clock by −2.1 s aligns it with 1,980 crowd clips [O1].\nConfidence: 0.58',
},
climb: {
id: 'climb', sit: 'climb', title: 'Free-climber on a Midtown tower', place: 'Midtown Manhattan (fictional tower)', date: 'this morning',
window: { start: Date.now() - 11 * HR_, end: Date.now() - 7 * HR_ }, people: ['news', 'sky', 'kora', 'geo', 'ais'],
cam: { lat: 40.7533, lon: -73.9866, range: 0.00034, heading: 0.62, tilt: 1.2 },
obs: [
{ id: 'O1', i: 'helicopter', f: 'sky', t: 'ADS-B tracks: 3 news helicopters orbiting the tower', src: 'ADS-B (illustrative callsigns)', at: Date.now() - 9 * HR_, meta: ['1 Hz', '2 h 14 m'] },
{ id: 'O2', i: 'video', f: 'eyes', t: 'Broadcast frames, time-coded', src: 'Public broadcast (illustrative)', at: Date.now() - 9 * HR_, meta: ['29.97 fps'] },
{ id: 'O3', i: 'seal', f: 'eyes', t: '41 signed street-level clips', src: 'Signed bundles (illustrative)', at: Date.now() - 9 * HR_, meta: ['IMU ✓', 'GPS ±5 m'] },
{ id: 'O4', i: 'cube', f: 'infra', t: 'Tower model, 412 m (fictional building)', src: 'Model', at: Date.now() - 400 * 24 * HR_, meta: ['static'] },
],
hyps: [
{ id: 'H1', by: 'news', text: 'The climb started at 06:52 local on the northeast face.', conf: 0.77, ci: [0.66, 0.86], cites: ['O2', 'O3'], agree: 58, dispute: 4, status: 'supported' },
{ id: 'H2', by: 'geo', text: 'Climber height over time is recoverable to ±3 m by triangulating helicopter camera positions (from ADS-B) with frame bearings.', conf: 0.69, ci: [0.55, 0.8], cites: ['O1', 'O2', 'O4'], agree: 33, dispute: 7, status: 'supported' },
],
beats: [
{ at: Date.now() - 10.5 * HR_, t: '06:52', text: 'First witness clip from the street: the climber is at the 3rd setback.', cites: ['H1', 'O3'], cam: { lat: 40.7538, lon: -73.9852, range: 0.00022, heading: 0.5, tilt: 1.3 } },
{ at: Date.now() - 9.5 * HR_, t: '07:55', text: 'Three news helicopters in orbit; their ADS-B tracks pin each broadcast camera.', cites: ['O1', 'H2'], cam: { lat: 40.751, lon: -73.99, range: 0.0012, heading: 0.7, tilt: 1.0 } },
{ at: Date.now() - 8.6 * HR_, t: '09:06', text: 'The climber reaches the crown at 384 m.', cites: ['H2'], cam: { lat: 40.7549, lon: -73.9848, range: 0.0003, heading: 0.4, tilt: 1.42 } },
],
suggestions: ['Triangulate the climber at 08:30 from two helicopters', 'Which face was the route on?'],
fallback: 'At 08:30 the bearings from N9 NEWS and SKY 4 [O1] to the climber in broadcast frames [O2] intersect 232 ± 4 m up the northeast face of the tower model [O4].\nConfidence: 0.66',
},
};
THR.data = THREADS;
/* ---------- 3D extras per reconstruction (GPU sets, built lazily) ---------- */
const EXTRAS = {};
GE.extrasFor = function (id) {
if (EXTRAS[id] !== undefined) return EXTRAS[id];
const T = THREADS[id] || (GE.SIT.byId[id]?.thread && THREADS[GE.SIT.byId[id].thread]);
if (!T) return (EXTRAS[id] = null);
const sp = [], ln = [];
const gold = [1, 0.84, 0.42, 1];
if (T.id === 'koshi') {
GE.scenes.ensure(GE.scenes.byId.koshi);
const gz = (la, lo) => GE.scenes.groundAt(la, lo) / 1000;
for (const o of T.obs) if (o.lat) { const z = gz(o.lat, o.lon); pushSpr(sp, geo.vec(o.lat, o.lon, z + 0.09), gold, 26, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [geo.vec(o.lat, o.lon, z), geo.vec(o.lat, o.lon, z + 0.09)], [1, 0.85, 0.5, 0.9]);
// capture frustum toward the river
const brg = { O5: 214, O6: 352, O7: 160 }[o.id] * D2R || 0; const tip = geo.vec(o.lat, o.lon, z + 0.012);
for (const da of [-0.35, 0.35]) for (const dz of [-0.04, 0.04]) { const [la2, lo2] = geo.dest(o.lat, o.lon, brg + da, 0.45); pushPolyline(ln, [tip, geo.vec(la2, lo2, gz(la2, lo2) + 0.012 + dz)], [1, 0.82, 0.4, 0.55]); }
}
// gauge & villages
pushSpr(sp, geo.vec(28.158, 85.336, gz(28.158, 85.336) + 0.06), [0.4, 0.75, 1, 1], 20, 9, -10, -1e9, 1e9, 0, 0.3, 0.4);
// rescue helicopter track (illustrative) up the valley
const trk = KO.map(([la, lo], i) => geo.vec(la, lo, gz(la, lo) + 0.35 + (i % 3) * 0.05)); pushPolyline(ln, trk, [0.62, 1, 0.6, 0.7], 1);
} else if (T.id === 'concert') {
const c = [51.556, -0.2796]; const stage = geo.vec(...geo.offset(c[0], c[1], -40, 0), 0.015);
const r = rng(9);
for (let i = 0; i < 160; i++) { const a = r() * TAU; const rx = 70 + r() * 55, ry = 52 + r() * 50; const [la, lo] = geo.offset(c[0], c[1], Math.cos(a) * rx, Math.sin(a) * ry); const z = 0.008 + r() * 0.03; const p = geo.vec(la, lo, z); pushSpr(sp, p, [1, 0.86, 0.45, 1], 7, 0, -10, -1e9, 1e9, 0, 0, 0.6); if (i % 2 === 0) pushPolyline(ln, [p, v3.lerp(p, stage, 0.35 + r() * 0.2)], [1, 0.8, 0.4, 0.28]); }
pushSpr(sp, stage, [1, 0.5, 1, 1], 30, 9, -10, -1e9, 1e9, 0, 0.1, 0.9);
} else if (T.id === 'climb') {
const tw = [40.7553, -73.9838];
for (const hc of GE.HELI_CIRCUITS.filter((x) => x.orbit && x.lat === 40.7553)) { const pts = []; for (let k = 0; k <= 64; k++) { const [la, lo] = geo.dest(hc.lat, hc.lon, (k / 64) * TAU, hc.r); pts.push(geo.vec(la, lo, hc.alt)); } pushPolyline(ln, pts, [0.62, 1, 0.6, 0.55], 1); }
const r = rng(5);
for (let i = 0; i < 41; i++) { const a = r() * TAU, d = 0.12 + r() * 0.35; const [la, lo] = geo.dest(tw[0], tw[1], a, d); const p = geo.vec(la, lo, 0.002); pushSpr(sp, p, [1, 0.84, 0.42, 1], 11, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [p, geo.vec(tw[0], tw[1], 0.12 + r() * 0.25)], [1, 0.8, 0.4, 0.22]); }
}
const set = { sprites: sp.length ? [makeSpriteSet('x' + id, sp)] : null, lines: ln.length ? [makeLineSet(ln)] : null };
return (EXTRAS[id] = set);
};
GE.setExtras = function (id) { GE.world.activeExtras = id ? GE.extrasFor(id) : null; GE.world.activeExtrasId = id; };
/* ---------- ledger (real SHA-256 hash chain) ---------- */
THR.ledgers = {};
async function buildLedger(T) {
const entries = []; let ts = T.window.start - 6 * HR_;
const push = (type, actor, payload) => { entries.push({ i: entries.length, ts: (ts += 7 * MIN + entries.length * 3 * MIN), type, actor, payload }); };
push('thread.create', 'kora_maps', { title: T.title, place: T.place });
for (const o of T.obs) push('observation.add', o.src.startsWith('@') ? o.src.split(' ')[0].slice(1) : 'ingest', { id: o.id, title: o.t, source: o.src, at: o.at, contentHash: o.hash });
for (const hy of T.hyps) { push(hy.ai ? 'ai.proposal' : 'hypothesis.propose', hy.ai ? 'claude' : P[hy.by].h, hy.ai ? { id: hy.id, text: hy.text, cites: hy.cites, prompt: hy.prompt, model: 'claude (window.claude sample)', diff: '+' + hy.id } : { id: hy.id, text: hy.text, cites: hy.cites, confidence: hy.conf }); push('vote', P[T.people[(entries.length) % T.people.length]].h, { target: hy.id, agree: true }); }
let prev = '0'.repeat(64);
for (const e of entries) { e.digest = await sha256(canon(e.payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); prev = e.hash; }
THR.ledgers[T.id] = entries; return entries;
}
THR.append = async function (T, type, actor, payload) {
const L = THR.ledgers[T.id] || (await buildLedger(T)); const prev = L[L.length - 1].hash;
const e = { i: L.length, ts: Date.now(), type, actor, payload }; e.digest = await sha256(canon(payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); L.push(e); return e;
};
THR.verify = async function (L, onStep) {
let prev = '0'.repeat(64), ok = true;
for (const e of L) { const dg = await sha256(canon(e.payload)); const hh = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: dg, prev })); const good = ok && dg === e.digest && e.prev === prev && hh === e.hash; if (!good) ok = false; onStep && (await onStep(e, good)); prev = e.hash; }
return ok;
};
THR.init = async function () {
for (const T of Object.values(THREADS)) { for (const o of T.obs) o.hash = await sha256(canon({ id: o.id, t: o.t, src: o.src, at: o.at, meta: o.meta })); await buildLedger(T); }
};
/* ---------- list ---------- */
THR.render = function () {
const v = $('#v-threads');
v.innerHTML = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Threads</div><div class="lt"><div class="kicker">${ic('seal', 'xs')} Signed evidence · open ledgers</div><div class="row"><h1>Threads</h1><button class="avatar md" style="--h:205" id="thMe">Y</button></div></div>
<div class="livebar" style="padding-bottom:14px"><span class="presence"><span class="stack">${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `<span class="avatar sm on" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span><span><b class="num">37</b> people reconstructing now</span></div>
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `<article class="tcard" data-t="${T.id}" data-id="${T.sit}"><div class="thumb"><canvas width="640" height="360"></canvas><div class="bl"><div><h3>${esc(T.title)}</h3><p>${esc(T.place)} · ${esc(T.date)}</p></div><span class="badge d4">4D</span></div></div><div class="stats"><div><b>${T.obs.length}</b>observations</div><div><b>${T.hyps.length}</b>hypotheses</div><div><b>${THR.ledgers[T.id]?.length || '–'}</b>ledger</div><span class="stack">${T.people.slice(0, 4).map((k) => `<span class="avatar sm" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></div></article>`; }).join('')}
<div style="padding:6px 14px 150px"><button class="btn" style="width:100%" id="newThread">${ic('plus', 'sm')}Start a reconstruction</button><p style="font-size:11.5px;color:var(--text3);text-align:center;margin-top:12px">Concept · illustrative data. Witness posts, handles and claims are fictional.</p></div></div>
<div class="ws" id="ws"></div>`;
$('#thMe').addEventListener('click', () => APP.setTab('you'));
$('#newThread').addEventListener('click', () => APP.toast(`${ic('threads', 'sm')} New thread · add your first observation`));
$$('.tcard').forEach((c) => {
c.addEventListener('click', () => THR.openWs(c.dataset.t));
const cv = c.querySelector('canvas'); const s = GE.SIT.byId[c.dataset.id]; const done = GE.thumbs.done.get(s.id);
if (done) cv.getContext('2d').drawImage(done, 0, 0, cv.width, cv.height); else { GE.thumbs.fallback(s, cv); }
});
};
/* ---------- workspace ---------- */
THR.openWs = function (id) {
const T = THREADS[id]; if (!T) return; const W = GE.world;
if (APP.tab !== 'threads') APP.setTab('threads');
THR.ws = { T, seg: THR.ws?.T === T ? THR.ws.seg : 'obs', beats: T.beats, playing: false };
$('#tlist').style.display = 'none';
const ws = $('#ws'); ws.classList.add('on');
W.setWindow(T.window); W.setTime(T.beats[Math.min(2, T.beats.length - 1)].at);
GE.setExtras(T.id);
for (const lid of ['signed', 'holo', 'helis']) W.layerById[lid].on = true;
const wide = APP.isWide();
Cam.autoSpin = 0; Cam.flyTo({ ...T.cam, shiftY: wide ? 0 : 0.6, shiftX: 0 });
APP.setMode('workspace');
THR.renderWs();
};
THR.closeWs = function (silent) {
if (!THR.ws) return; THR.stop(); THR.ws = null; const W = GE.world;
$('#ws').classList.remove('on'); $('#ws').innerHTML = ''; const tl = $('#tlist'); if (tl) tl.style.display = '';
W.setWindow(null); W.setTime(null); GE.setExtras(null); APP.setMode('');
if (!silent) Cam.flyTo(APP.heroCam());
};
THR.renderWs = function () {
const { T, seg } = THR.ws; const ws = $('#ws');
ws.innerHTML = `<div class="wsh"><button class="circ glass" id="wsBack" aria-label="Back">${ic('chevl')}</button><div class="tt glass"><span style="min-width:0;flex:1"><b>${esc(T.title)}</b><span>${esc(T.place)} · ${esc(T.date)}</span></span><span class="presence"><span class="stack">${T.people.slice(0, 4).map((k, i) => `<span class="avatar sm ${i < 3 ? 'on' : ''}" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span></div></div>
<div class="body glass strong"><div class="seg" id="wsSeg">${[['obs', 'Observations'], ['hyp', 'Hypotheses'], ['nar', 'Narrative'], ['led', 'Ledger']].map(([k, l]) => `<button data-s="${k}" class="${seg === k ? 'on' : ''}">${l}</button>`).join('')}</div><div class="wsc" id="wsc"></div>
${seg === 'hyp' ? `<div class="composer glass strong" id="composer"><div class="sug">${T.suggestions.map((s) => `<button>${esc(s)}</button>`).join('')}</div><div class="row"><textarea id="aiIn" rows="1" placeholder="Ask AI to refine this reconstruction…"></textarea><button class="send" id="aiSend" aria-label="Send">${ic('arrowup')}</button></div></div>` : ''}</div>`;
$('#wsBack').addEventListener('click', () => THR.closeWs());
$$('#wsSeg button').forEach((b) => b.addEventListener('click', () => { THR.ws.seg = b.dataset.s; THR.renderWs(); haptic(4); }));
const c = $('#wsc');
if (seg === 'obs') c.innerHTML = `<div class="stratum">${ic('lock', 'xs')} Stratum 1 · Observations — immutable, hashed, sourced</div>` + T.obs.map((o) => `<div class="obs" data-o="${o.id}" style="--c:${GE.FAM[o.f].color}"><span class="oi">${ic(o.i)}</span><div><div class="ot"><span class="tagid">${o.id}</span> ${esc(o.t)}</div><div class="om"><span>${esc(o.src)}</span><span class="num">${fmt.utc(o.at)}</span>${o.meta.map((m) => `<span>${esc(m)}</span>`).join('')}<span class="mono">sha256 ${o.hash ? o.hash.slice(0, 12) + '…' : '…'}</span></div></div></div>`).join('');
if (seg === 'hyp') { c.innerHTML = `<div class="stratum">${ic('people', 'xs')} Stratum 2 · Hypotheses — cite, agree, dispute, supersede</div><div id="hypList"></div>`; THR.renderHyps(); THR.bindComposer(); }
if (seg === 'nar') THR.renderNarr(c);
if (seg === 'led') THR.renderLedger(c);
c.querySelectorAll('[data-o]').forEach((el) => el.addEventListener('click', () => { const o = T.obs.find((x) => x.id === el.dataset.o); if (o.lat) Cam.flyTo({ lat: o.lat - 0.004, lon: o.lon - 0.002, range: 0.0012, tilt: 1.15, heading: 0.4, shiftY: APP.isWide() ? 0 : 0.6 }); GE.world.setTime(o.at); APP.toast(`${ic('hash', 'sm')} ${o.id} · sha256 ${o.hash.slice(0, 16)}…`); }));
};
const confSVG = (h) => { const w = 200; return `<svg viewBox="0 0 ${w} 14" preserveAspectRatio="none"><rect x="0" y="5" width="${w}" height="4" rx="2" fill="rgba(255,255,255,.08)"/><rect x="${h.ci[0] * w}" y="3" width="${(h.ci[1] - h.ci[0]) * w}" height="8" rx="4" fill="${h.ai ? 'rgba(183,156,255,.3)' : 'rgba(124,196,255,.25)'}"/><rect x="${h.conf * w - 1.5}" y="1" width="3" height="12" rx="1.5" fill="${h.ai ? '#B79CFF' : '#7CC4FF'}"/></svg>`; };
THR.hypHTML = function (hy) {
const p = P[hy.by] || P.ai;
return `<div class="hyp ${hy.ai ? 'ai' : ''}" data-h="${hy.id}"><div class="hh">${hy.ai ? `<span class="avatar sm ai">${ic('sparkles', 'xs')}</span>` : `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span>`}<b>${hy.ai ? 'AI proposal' : '@' + p.h}</b><span class="tagid">${hy.id}</span><span class="stt ${hy.status}">${hy.status}</span></div>
<div class="hb" data-text>${esc(hy.text)}</div>
<div class="conf"><span>confidence</span>${confSVG(hy)}<b class="num" style="color:var(--text)">${Math.round(hy.conf * 100)}%</b></div>
<div class="cites">${hy.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}${hy.prompt ? `<span style="font-size:11.5px;color:var(--text3)">prompt: “${esc(hy.prompt)}” · ${esc(hy.model || 'claude')}</span>` : ''}</div>
<div class="hf"><button class="vote ${hy.myVote === 1 ? 'on' : ''}" data-v="1">${ic('thumbup', 'sm')}<span class="num">${hy.agree + (hy.myVote === 1 ? 1 : 0)}</span></button><button class="vote dis ${hy.myVote === -1 ? 'on' : ''}" data-v="-1">${ic('thumbdown', 'sm')}<span class="num">${hy.dispute + (hy.myVote === -1 ? 1 : 0)}</span></button><button class="vote" data-v="0" style="margin-left:auto">${ic('bubble', 'sm')}Cite</button></div></div>`;
};
THR.renderHyps = function () {
const { T } = THR.ws; const host = $('#hypList'); if (!host) return;
host.innerHTML = T.hyps.map(THR.hypHTML).join('');
host.querySelectorAll('[data-h]').forEach((el) => el.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', async () => {
const hy = T.hyps.find((x) => x.id === el.dataset.h); const v = +b.dataset.v; haptic(5);
if (v === 0) { APP.toast(`${ic('bubble', 'sm')} Cite ${hy.id} in a new hypothesis`); return; }
hy.myVote = hy.myVote === v ? 0 : v; await THR.append(T, 'vote', 'you', { target: hy.id, agree: v === 1, retract: hy.myVote === 0 });
THR.renderHyps(); APP.toast(`${ic('link', 'sm')} Vote recorded in the ledger`);
})));
};
THR.bindComposer = function () {
const ta = $('#aiIn'); const send = $('#aiSend'); if (!ta) return;
$$('#composer .sug button').forEach((b) => b.addEventListener('click', () => { ta.value = b.textContent; ta.focus(); }));
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send.click(); } });
send.addEventListener('click', () => { const q = ta.value.trim() || THR.ws.T.suggestions[0]; ta.value = ''; THR.refine(q); });
};
THR.refine = async function (q) {
const { T } = THR.ws; const send = $('#aiSend'); if (send) send.disabled = true;
const id = 'H' + (T.hyps.length + 1);
const hy = { id, by: 'ai', ai: true, text: '', conf: 0.5, ci: [0.35, 0.65], cites: [], agree: 0, dispute: 0, status: 'proposed', prompt: q, model: '…' };
T.hyps.push(hy); THR.renderHyps();
const card = $(`[data-h="${id}"]`); card?.scrollIntoView({ behavior: 'smooth', block: 'end' });
const textEl = () => $(`[data-h="${id}"] [data-text]`);
const promptStr = `You are helping a group of people reconstruct a real-world event from evidence, in an app that keeps observations (immutable) separate from hypotheses (claims).\nEvent: ${T.title} — ${T.place} (${T.date}). Some witness posts are illustrative.\nObservations:\n${T.obs.map((o) => `[${o.id}] ${o.t} — ${o.src}, ${new Date(o.at).toISOString().slice(0, 16)}Z (${o.meta.join('; ')})`).join('\n')}\nCurrent hypotheses:\n${T.hyps.filter((x) => x.id !== id).map((x) => `[${x.id}] ${x.text} (confidence ${x.conf})`).join('\n')}\nRequest from a participant: "${q}"\nReply with ONE new proposed hypothesis in at most 70 words. Cite observation IDs in square brackets like [O3]. Do not invent new observations. End with a final line exactly like "Confidence: 0.xx".`;
let out = '', model = 'scripted fallback (no AI available here)';
const render = (txt) => { out = txt; const el = textEl(); if (el) el.textContent = txt.replace(/\n?Confidence:.*$/s, '').trim() || '…'; };
let sample = null;
try { sample = window.claude?.use ? await window.claude.use('sample') : null; } catch { sample = null; }
if (sample) {
try { const r = await sample(promptStr, { onText: ({ text }) => render(text) }); render(r?.text ?? out); model = 'claude · window.claude sample'; }
catch (e) { sample = null; }
}
if (!sample) { const full = T.fallback; for (let i = 1; i <= full.length; i += 3) { render(full.slice(0, i)); await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 12)); } render(full); }
const cm = /Confidence:\s*([0-9.]+)/i.exec(out); hy.conf = cm ? clamp(parseFloat(cm[1]), 0.05, 0.95) : 0.55; hy.ci = [Math.max(0, hy.conf - 0.16), Math.min(1, hy.conf + 0.14)];
hy.cites = [...new Set((out.match(/\[(O\d+)\]/g) || []).map((x) => x.slice(1, -1)))]; hy.text = out.replace(/\n?Confidence:.*$/s, '').trim(); hy.model = model;
const e = await THR.append(T, 'ai.proposal', 'claude', { id, prompt: q, model, inputs: hy.cites, output: hy.text, diff: '+' + id });
THR.renderHyps(); if (send) send.disabled = false;
APP.toast(`${ic('sparkles', 'sm')} Proposed ${id} · ledger #${e.i} · ${e.hash.slice(0, 10)}…`, 3000);
};
/* narrative */
THR.renderNarr = function (c) {
const { T } = THR.ws;
c.innerHTML = `<div class="stratum">${ic('play', 'xs')} Stratum 3 · Narrative — a camera path through space-time</div><div class="playbar"><button class="playbtn" id="play" aria-label="Play">${ic(THR.ws.playing ? 'pause' : 'play')}</button><div style="flex:1"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-bottom:6px"><span class="num" id="narT">${fmt.utc(GE.world.simMs)}</span><span>${T.beats.length} beats</span></div><div class="prog"><i id="narP"></i></div></div></div><div class="narr">${T.beats.map((b, i) => `<div class="beat" data-b="${i}"><span class="bt">${b.t}<br><span style="font-size:10px;color:var(--text3)">UTC</span></span><div><p>${esc(b.text)}</p><div class="cites">${b.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}</div></div></div>`).join('')}</div>`;
$('#play').addEventListener('click', () => (THR.ws.playing ? THR.stop() : THR.play()));
c.querySelectorAll('[data-b]').forEach((el) => el.addEventListener('click', () => THR.goBeat(+el.dataset.b)));
};
THR.goBeat = function (i, dur) { const { T } = THR.ws; const b = T.beats[i]; THR.ws.beat = i; Cam.flyTo({ ...b.cam, shiftY: APP.isWide() ? 0 : 0.6 }, { duration: dur ?? 2.2 }); GE.world.setTime(b.at); $$('.beat').forEach((x) => x.classList.toggle('on', +x.dataset.b === i)); };
THR.play = function () {
const ws = THR.ws; ws.playing = true; const T = ws.T; const per = 3.6; const t0 = performance.now(); let last = -1;
const btn = $('#play'); if (btn) btn.innerHTML = ic('pause');
const step = () => {
if (!THR.ws || !THR.ws.playing) return; const el = (performance.now() - t0) / 1000; const total = per * T.beats.length;
const f = clamp(el / total, 0, 1); const bi = Math.min(T.beats.length - 1, Math.floor(el / per));
if (bi !== last) { last = bi; THR.goBeat(bi, per * 0.9); }
const a = T.beats[bi], b = T.beats[Math.min(T.beats.length - 1, bi + 1)]; const u = clamp((el - bi * per) / per, 0, 1);
GE.world.setTime(lerp(a.at, b.at, bi === T.beats.length - 1 ? 0 : u));
const p = $('#narP'); if (p) p.style.width = (f * 100).toFixed(1) + '%'; const nt = $('#narT'); if (nt) nt.textContent = fmt.utc(GE.world.simMs);
if (f >= 1) { THR.stop(); return; } requestAnimationFrame(step);
};
step();
};
THR.stop = function () { if (!THR.ws) return; THR.ws.playing = false; const btn = $('#play'); if (btn) btn.innerHTML = ic('play'); };
/* ledger */
THR.renderLedger = function (c) {
const { T } = THR.ws; const L = THR.ledgers[T.id] || [];
c.innerHTML = `<div class="stratum">${ic('link', 'xs')} Ledger — SHA-256 hash chain · ${L.length} entries</div><div class="btnrow" style="margin:4px 0 10px"><button class="btn sm pri" id="lv">${ic('check', 'sm')}Verify chain</button><button class="btn sm" id="lt">${THR._tampered ? 'Restore entry #3' : 'Tamper with #3'}</button><button class="btn sm" id="lx">${ic('share', 'sm')}Export</button></div><div class="ledger" id="led">${L.map((e) => `<div class="le" data-i="${e.i}"><div class="lh"><span class="ix">#${e.i}</span><span class="ty">${esc(e.type)} <span>· @${esc(e.actor)}</span></span><span class="vk" data-vk></span></div><div style="margin-left:34px" class="hs">${e.hash.slice(0, 20)}… ← ${e.prev.slice(0, 8)}…</div><pre>${esc(JSON.stringify({ i: e.i, ts: new Date(e.ts).toISOString(), type: e.type, actor: e.actor, payload: e.payload, digest: e.digest, prev: e.prev, hash: e.hash }, null, 1))}</pre></div>`).join('<div class="chain"></div>')}</div>`;
c.querySelectorAll('.le .lh').forEach((x) => x.addEventListener('click', () => x.parentElement.classList.toggle('open')));
$('#lv').addEventListener('click', async () => {
c.querySelectorAll('[data-vk]').forEach((x) => { x.className = 'vk'; x.innerHTML = ''; });
const ok = await THR.verify(L, async (e, good) => { const el = c.querySelector(`.le[data-i="${e.i}"] [data-vk]`); if (el) { el.className = 'vk ' + (good ? 'ok' : 'bad'); el.innerHTML = ic(good ? 'check' : 'x', 'xs'); el.scrollIntoView({ block: 'nearest' }); } await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 45)); });
APP.toast(ok ? `${ic('check', 'sm')} Chain verified · ${L.length} links intact` : `${ic('x', 'sm')} Chain broken at #3 — a payload no longer matches its digest`, 2800); haptic(ok ? 10 : [20, 60, 20]);
});
$('#lt').addEventListener('click', () => { const e = L[3]; if (!e) return; if (THR._tampered) { e.payload = THR._tampered; THR._tampered = null; } else { THR._tampered = e.payload; e.payload = { ...e.payload, title: (e.payload.title || e.payload.text || '') + ' (edited)' }; } THR.renderLedger(c); APP.toast(THR._tampered ? 'Entry #3 edited locally — now verify' : 'Entry #3 restored'); });
$('#lx').addEventListener('click', () => { try { const blob = new Blob([JSON.stringify(L, null, 1)], { type: 'application/json' }); const a = h('a', { href: URL.createObjectURL(blob), download: `ledger-${T.id}.json` }); document.body.append(a); a.click(); a.remove(); } catch {} APP.toast(`${ic('share', 'sm')} Ledger exported as JSON`); });
};
+68
View File
@@ -0,0 +1,68 @@
/* You: captures and their seals (+ verify inspector), holograms left in places, followed situations,
alert radius, rendering quality, the concept marker and credits. */
const YOU = (GE.you = { radius: store.get('radius', 250), follows: store.get('follows', { cyclone: true, koshi: true, aurora: true, iss: false, launch: true }) });
YOU.render = function () {
const v = $('#v-you'); const st = v.scrollTop; const W = GE.world;
const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')];
v.innerHTML = `<div style="background:var(--bg);min-height:100%;padding-bottom:150px"><div class="navbar">You</div>
<div class="lt"><div class="kicker">${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}</div><h1>You</h1></div>
<div class="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your window</h2><div style="color:var(--text2);font-size:13px;margin:2px 0 8px">Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}</div><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
<div class="stats4"><div><b>${caps.length}</b>captures</div><div><b>${holos.length}</b>holograms</div><div><b>${Object.values(YOU.follows).filter(Boolean).length}</b>following</div><div><b>${caps.length ? '100%' : '—'}</b>verified</div></div>
<div class="sec-h"><h2>Captures</h2><span>sealed on this device</span></div>
<div class="capgrid">${caps.length ? caps.map((c, i) => `<div class="capc" data-c="${i}"><div class="im" style="background-image:url(${c.thumb})"><svg class="seal"><use href="#i-seal"/></svg></div><div class="cm"><b>${esc(c.place)}</b><span class="num">${fmt.utc(c.at)}</span><div class="mono">${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}</div></div></div>`).join('') : `<div style="grid-column:1/-1;color:var(--text3);font-size:14px;padding:6px 4px">No captures yet — open Capture › Witness and press the shutter.</div>`}</div>
<div class="sec-h"><h2>Holograms</h2><span>left in places</span></div>
<div class="grp">${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `<div class="row2"><span class="ri" style="--c:var(--gold)">${ic('cube')}</span><span class="rt">${esc(x.title)}<small>${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}</small></span>${x.empty ? '' : `<button class="rv" data-fly="${x.lat},${x.lon}">${ic('chevr', 'sm')}</button>`}</div>`).join('')}</div>
<div class="sec-h"><h2>Following</h2><span>alerts</span></div>
<div class="grp">${Object.entries(YOU.follows).map(([id, on]) => { const s = GE.SIT.byId[id]; if (!s) return ''; return `<div class="row2"><span class="ri" style="--c:${GE.FAM[s.fam].color}">${ic(GE.FAM[s.fam].icon)}</span><span class="rt">${esc(s.title.split(' — ')[0])}<small>${esc(s.place)}</small></span><button class="sw2 ${on ? 'on' : ''}" data-f="${id}" role="switch" aria-checked="${on}" aria-label="Follow"></button></div>`; }).join('')}</div>
<div class="sec-h"><h2>Alert radius</h2><span class="num" id="radV">${YOU.radius} km</span></div>
<div class="grp radius"><svg viewBox="0 0 300 120" id="radSvg"></svg><input type="range" min="10" max="2000" step="10" value="${YOU.radius}" id="rad" aria-label="Alert radius"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text3)"><span>10 km</span><span id="radN" class="num"></span><span>2,000 km</span></div></div>
<div class="sec-h"><h2>Rendering</h2><span>adaptive</span></div>
<div class="grp"><div class="row2"><span class="ri" style="--c:var(--sky)">${ic('gauge')}</span><span class="rt">Resolution scale<small>Drops when frames take > 20 ms for 2 s</small></span><span class="rv num" id="qScale"></span></div>
<div class="row2"><span class="ri" style="--c:var(--sky)">${ic('cpu')}</span><span class="rt">Frame time<small>Post chain: atmosphere → FXAA → half-res bloom → grade</small></span><span class="rv num" id="qFps"></span></div>
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('sparkles')}</span><span class="rt">Bloom</span><button class="sw2 ${W.quality.bloom ? 'on' : ''}" data-q="bloom" role="switch" aria-label="Bloom"></button></div>
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('frame')}</span><span class="rt">Film grain</span><button class="sw2 ${W.quality.grain ? 'on' : ''}" data-q="grain" role="switch" aria-label="Grain"></button></div>
<div class="row2"><span class="ri" style="--c:var(--text3)">${ic('gyro')}</span><span class="rt">Reduced motion<small>Follows your system setting</small></span><span class="rv">${prefersReduced() ? 'On' : 'Off'}</span></div></div>
<div class="sec-h"><h2>About</h2><span>vision mockup</span></div>
<div class="grp credits"><p style="margin:0 0 10px"><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></p>
<p style="margin:0 0 8px"><b>God’s Eye</b> — the window through which you see the world, and the eye through which the world sees itself. This is a design vision: every flight, ship, satellite, quake, fire, cyclone, camera, person, handle, post and reconstruction claim here is synthetic or fictional, placed in real geography.</p>
<p style="margin:0 0 8px"><b>Imagery:</b> NASA Earth Observatory / Visible Earth — Blue Marble: Next Generation (cloudless day), Black Marble 2016 (night lights), Blue Marble cloud mosaic, land/water mask and topography. Public domain, courtesy of NASA.</p>
<p style="margin:0 0 8px"><b>Real crypto:</b> SHA-256 (WebCrypto) over every rendered capture and ledger entry; ECDSA P-256 signatures with a non-extractable key generated on this device.</p>
<p style="margin:0"><b>Renderer:</b> hand-written WebGL2 — ray-traced planet with single-scatter atmosphere, day/night terminator from the real sun position, ocean glint, clouds, stars, procedural cities and terrain, GPU-propagated flights and orbits.</p></div></div>`;
v.scrollTop = st;
v.querySelectorAll('[data-c]').forEach((el) => el.addEventListener('click', () => YOU.inspect(+el.dataset.c)));
v.querySelectorAll('[data-f]').forEach((el) => el.addEventListener('click', () => { const id = el.dataset.f; YOU.follows[id] = !YOU.follows[id]; el.classList.toggle('on', YOU.follows[id]); el.setAttribute('aria-checked', YOU.follows[id]); store.set('follows', YOU.follows); haptic(5); }));
v.querySelectorAll('[data-q]').forEach((el) => el.addEventListener('click', () => { const k = el.dataset.q; W.quality[k] = !W.quality[k]; el.classList.toggle('on', W.quality[k]); haptic(5); }));
v.querySelectorAll('[data-fly]').forEach((el) => el.addEventListener('click', () => { const [la, lo] = el.dataset.fly.split(',').map(Number); APP.setTab('globe'); Cam.flyTo({ lat: la, lon: lo, range: 0.002, tilt: 1.1 }); }));
const rad = $('#rad'); rad.addEventListener('input', () => { YOU.radius = +rad.value; $('#radV').textContent = YOU.radius + ' km'; store.set('radius', YOU.radius); YOU.drawRadius(); });
YOU.drawRadius();
};
YOU.drawRadius = function () {
const svg = $('#radSvg'); if (!svg) return; const W = GE.world; const me = GE.ME; const r = YOU.radius;
const spanKm = Math.max(600, r * 2.6); const sx = 300 / spanKm, sy = 120 / (spanKm * 0.4);
const P2 = (la, lo) => [150 + (lo - me.lon) * 111 * Math.cos(me.lat * D2R) * sx, 60 - (la - me.lat) * 111 * sy];
let s = `<rect width="300" height="120" fill="#070A10"/>`;
for (const c of GE.CITY_V) { const [x, y] = P2(c.lat, c.lon); if (x > -5 && x < 305 && y > -5 && y < 125) s += `<circle cx="${x}" cy="${y}" r="1.6" fill="rgba(255,210,140,.5)"/>`; }
let n = 0; for (const s0 of GE.SIT.list) { const d = geo.dist(me.lat, me.lon, s0.lat, s0.lon) * EARTH_KM; const [x, y] = P2(s0.lat, s0.lon); if (d < r) n++; if (x > 0 && x < 300 && y > 0 && y < 120) s += `<circle cx="${x}" cy="${y}" r="3" fill="${GE.FAM[s0.fam].color}"/>`; }
for (const q of GE.QUAKES) { const d = geo.dist(me.lat, me.lon, q.lat, q.lon) * EARTH_KM; if (d < r) n++; }
s += `<ellipse cx="150" cy="60" rx="${r * sx}" ry="${r * sy}" fill="rgba(124,196,255,.08)" stroke="#7CC4FF" stroke-width="1.2" stroke-dasharray="4 3"/><circle cx="150" cy="60" r="3.5" fill="#fff"/><text x="156" y="57" font-size="10" fill="rgba(255,255,255,.75)" font-family="var(--font)">${esc(me.name)}</text>`;
svg.innerHTML = s; const nn = $('#radN'); if (nn) nn.textContent = `${n} alerts in range this week`;
};
YOU.tick = function () { const a = $('#qScale'); if (a) a.textContent = `${GE.gov.scale.toFixed(2)}× · ${GE.gov.w}×${GE.gov.h}`; const b = $('#qFps'); if (b) b.textContent = `${GE.gov.ms.toFixed(1)} ms`; };
YOU.inspect = async function (i) {
const c = GE.captures[i]; if (!c) return; const el = $('#inspector');
el.innerHTML = `<div class="panel glass strong"><div class="grab" style="height:22px;display:grid;place-items:center"><span style="width:38px;height:5px;border-radius:3px;background:rgba(255,255,255,.3)"></span></div>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px"><div style="width:92px;height:69px;border-radius:12px;background:url(${c.thumb}) center/cover;flex:none;position:relative"><svg class="seal" style="position:absolute;right:-6px;top:-6px"><use href="#i-seal"/></svg></div><div><div style="font-weight:700;font-size:17px">Verify capture</div><div style="color:var(--text2);font-size:13px">${esc(c.place)} · <span class="num">${fmt.utc(c.at)}</span></div></div><button class="circ" id="insX" style="margin-left:auto;background:rgba(255,255,255,.08)">${ic('x')}</button></div>
<div class="steps" id="vsteps">${['Media hash recomputed (SHA-256)', 'Bundle hash recomputed', 'Signature valid — ECDSA P-256, device public key', 'IMU agrees with the image horizon (±0.8°)', 'GPS track agrees with capture time', 'Chain link to previous bundle'].map((t, k) => `<div class="step" data-k="${k}"><span class="ck">${ic('check', 'xs')}</span><div><b>${t}</b><div class="h" data-h></div></div></div>`).join('')}</div>
<div style="margin-top:10px;font-size:11px;color:var(--text3);font-family:var(--mono);word-break:break-all">pubkey x ${c.jwk?.x || '—'}<br>pubkey y ${c.jwk?.y || '—'}</div>
<p style="font-size:11.5px;color:var(--text3);margin:10px 0 0">In a native build the key lives in the Secure Enclave / StrongBox and the bundle is written as a C2PA manifest.</p></div>`;
el.classList.add('on');
$('#insX').addEventListener('click', () => el.classList.remove('on'));
el.addEventListener('click', (e) => { if (e.target === el) el.classList.remove('on'); });
const mark = async (k, ok, txt) => { await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 260)); const s = $(`#vsteps [data-k="${k}"]`); if (!s) return; s.classList.add('on'); if (ok) s.classList.add('done'); else s.querySelector('.ck').style.cssText = 'background:var(--live);box-shadow:none;color:#fff'; s.querySelector('[data-h]').textContent = txt; haptic(3); };
const mh = await sha256(c.bytes); await mark(0, mh === c.mediaHash, mh);
const bh = await sha256(canon({ media: mh, session: c.session, context: c.context, prev: c.prev })); await mark(1, bh === c.hash, bh);
const ok = await GE.crypto.verifyHex(c.hash, c.sig); await mark(2, ok, ok ? (c.sig || '').slice(0, 64) + '…' : 'no signature available in this browser');
await mark(3, true, `roll ${c.session.imu[0].g}° · pitch ${c.session.imu[0].b}° vs rendered horizon`);
await mark(4, true, `${c.session.gps.length} fixes within ${(c.session.end - c.session.start) / 1000} s of the shutter`);
const prev = i > 0 ? GE.captures[i - 1].hash : '0'.repeat(64); await mark(5, prev === c.prev, `prev ${c.prev.slice(0, 32)}…`);
};
+103
View File
@@ -0,0 +1,103 @@
/* Boot, render loop, adaptive-DPR governor, cold open. */
const HASH = (() => { const o = {}; try { (location.hash || '').slice(1).split('&').forEach((kv) => { const [k, v] = kv.split('='); if (k) o[decodeURIComponent(k)] = v == null ? true : decodeURIComponent(v); }); } catch {} return o; })();
GE.HASH = HASH;
const gov = (GE.gov = { levels: [1, 0.85, 0.72, 0.6, 0.5], li: 0, scale: 1, ms: 16, w: 0, h: 0, acc: 0, n: 0, slow: 0, fast: 0, pinned: HASH.dpr ? parseFloat(HASH.dpr) : null });
let chain = null; const canvas = $('#gl');
function sizeCanvas() {
const cw = canvas.clientWidth || innerWidth, chh = canvas.clientHeight || innerHeight;
const dpr = Math.min(devicePixelRatio || 1, 2);
const s = gov.pinned || dpr * gov.levels[gov.li]; gov.scale = s;
const w = Math.max(2, Math.round(cw * s)), hh = Math.max(2, Math.round(chh * s));
if (canvas.width !== w || canvas.height !== hh) { canvas.width = w; canvas.height = hh; }
if (!chain || chain.w !== w || chain.h !== hh) { chain && chain.dispose(); chain = GL.chain(w, hh); }
gov.w = w; gov.h = hh; return [cw, chh];
}
function governor(dt) {
if (gov.pinned) return;
gov.ms = lerp(gov.ms, dt * 1000, 0.08);
if (gov.ms > 21) { gov.slow += dt; gov.fast = 0; } else if (gov.ms < 13.5) { gov.fast += dt; gov.slow = 0; } else { gov.slow = Math.max(0, gov.slow - dt); gov.fast = 0; }
if (gov.slow > 2 && gov.li < gov.levels.length - 1) { gov.li++; gov.slow = 0; bus.emit('quality', { scale: gov.scale, fps: 1000 / gov.ms }); }
if (gov.fast > 5 && gov.li > 0) { gov.li--; gov.fast = 0; }
W.quality.steps = gov.li >= 3 ? 8 : 12;
}
function globeVisible() {
if (APP.intro) return true;
if (APP.isWide()) return true;
if (APP.tab === 'globe' || APP.tab === 'capture' || APP.mode) return true;
if (APP.tab === 'now') { const v = $('#v-now'); return v.scrollTop < v.querySelector('.now-hero')?.offsetHeight + 40; }
return false;
}
let lastT = performance.now(), lastDyn = 0, lastSec = 0, running = true;
function frame(now) {
if (!running) return;
const dt = Math.min(0.1, Math.max(0.001, (now - lastT) / 1000)); lastT = now;
W.simMs = Date.now() + W.offsetH * 3600000; W.t = (W.simMs - NOW) / 1000; W.sun = astro.sun(W.simMs);
Cam.update(dt);
if (APP.mode === 'situation' && !Cam.anim && GE.sheet.targetShift != null && !APP.isWide()) Cam.shiftY += (GE.sheet.targetShift - Cam.shiftY) * Math.min(1, dt * 7);
if (globeVisible()) {
const [cw, chh] = sizeCanvas();
if (now - lastDyn > 40) { W.updateDynamic(); lastDyn = now; }
const view = Cam.view(cw, chh); GE.mainView = view;
const ov = GE.renderOverride || {};
W.render(view, chain, null, { ...ov, focus: W.focus, extraSprites: W.activeExtras?.sprites, extraLines: W.activeExtras?.lines, fade: GE.introFade ?? 1 });
APP.updateLabels(view); GE.ride.frame(view); GE.capture.frame(view);
}
if (APP.tab === 'globe' || APP.isWide() || APP.mode) GE.gui.ribbonFrame();
GE.thumbs.step(APP.intro ? 14 : 8);
if (now - lastSec > 1000) { lastSec = now; W.updateCounts(); GE.now.tick(); GE.sheet.tick(); GE.you.tick(); }
governor(dt);
requestAnimationFrame(frame);
}
document.addEventListener('visibilitychange', () => { if (document.hidden) running = false; else if (!running) { running = true; lastT = performance.now(); requestAnimationFrame(frame); } });
/* ---------- cold open ---------- */
function coldOpen() {
const cold = $('#cold'); const app = $('#app');
const skipAll = HASH.nointro || HASH.tab;
const red = prefersReduced();
W.sun = astro.sun(Date.now()); const finalCam = APP.heroCam();
const end = () => {
if (!APP.intro) return; APP.intro = false; GE.introFade = 1; W.setTime(null);
cold.classList.add('out'); app.classList.remove('intro'); setTimeout(() => cold.remove(), 1100);
Cam.cancel(); if (Cam.range > 2.2 && !APP.isWide()) Cam.flyTo(finalCam, { duration: 0.6 });
Cam.autoSpin = 0.3; APP.updateChrome(); APP.moveLens();
if (HASH.tab) APP.setTab(HASH.tab, { force: true });
};
APP.intro = true; app.classList.add('intro'); APP.updateChrome();
if (skipAll) { Cam.set(finalCam); end(); return; }
Cam.set({ lat: 8, lon: finalCam.lon + 75, range: 5.8, heading: 0, tilt: 0, shiftY: 0.0, shiftX: 0 });
const T = red ? 1.2 : 3.4; const t0 = performance.now();
GE.introFade = 0;
if (!red) Cam.flyTo(finalCam, { duration: T });
else Cam.set(finalCam);
const tick = () => {
if (!APP.intro) return; const u = clamp((performance.now() - t0) / 1000 / T, 0, 1);
GE.introFade = ease.out(clamp(u * 2.2, 0, 1));
if (!red) W.setTime(Date.now() - (1 - ease.outQuint(u)) * 9 * 3600000);
if (u > 0.12) cold.classList.add('s1'); if (u > 0.36) cold.classList.add('s2');
if (u >= 1) { setTimeout(end, red ? 200 : 500); return; } requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); Cam.set(finalCam); end(); });
cold.addEventListener('click', () => { Cam.set(finalCam); end(); });
}
/* ---------- boot ---------- */
async function boot() {
const gl = GL.init(canvas);
if (!gl) { document.body.insertAdjacentHTML('beforeend', '<div style="position:fixed;inset:0;display:grid;place-items:center;color:#fff;font:15px system-ui;padding:24px;text-align:center;background:#05070B">This vision needs WebGL2. Try a recent browser on a phone, tablet or desktop.</div>'); return; }
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); running = false; APP.toast('Graphics context lost — reopen to resume', 4000); });
await W.loadTextures();
W.buildStatic(); W.buildMovers(); W.updateCounts(); W.updateDynamic();
W.situationPins = GE.SIT.list.map(GE.SIT.pin).filter(Boolean);
APP.initShell(); GE.now.render(); GE.threads.render(); GE.you.render(); GE.gui.init(); GE.sheet.initDrag();
Cam.attach(canvas, () => GE.mainView || Cam.view(canvas.clientWidth, canvas.clientHeight));
sizeCanvas();
coldOpen();
requestAnimationFrame(frame);
GE.threads.init().then(() => { if (!GE.threads.ws) GE.threads.render(); });
GE.crypto.KEY.get().then(() => GE.you.render());
if (HASH.open) setTimeout(() => GE.sheet.open(HASH.open, {}), 300);
GE.ready = true;
}
boot().catch((e) => { console.error(e); });