mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
wip(mobile): checkpoint the app shell and vision mockup in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
56 files changed
+23645
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 462 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,49 @@
|
||||
// Builds mobile/vision/index.html — one self-contained file (no runtime network).
|
||||
//
|
||||
// node mobile/vision/build.mjs # build once
|
||||
// node mobile/vision/build.mjs --watch # rebuild on change
|
||||
//
|
||||
// Inputs (all offline, commit-friendly):
|
||||
// src/app.html page template; markers <!--@CSS--> <!--@JS--> <!--@TEX--> are replaced
|
||||
// src/app.css styles
|
||||
// src/js/*.js concatenated in filename order inside one IIFE
|
||||
// assets/*.webp NASA public-domain textures (made by prep-textures.mjs), inlined as data: URIs
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rd = (p) => fs.readFileSync(path.join(here, p), 'utf8');
|
||||
|
||||
function build() {
|
||||
const t0 = Date.now();
|
||||
const tpl = rd('src/app.html');
|
||||
const css = rd('src/app.css');
|
||||
const jsDir = path.join(here, 'src/js');
|
||||
const files = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js')).sort();
|
||||
const js = files.map((f) => `// ---- ${f}\n` + fs.readFileSync(path.join(jsDir, f), 'utf8')).join('\n');
|
||||
const tex = {};
|
||||
for (const f of fs.readdirSync(path.join(here, 'assets')).sort()) {
|
||||
if (!/\.(webp|png|jpg)$/.test(f)) continue;
|
||||
const ext = f.split('.').pop();
|
||||
const mime = ext === 'jpg' ? 'image/jpeg' : 'image/' + ext;
|
||||
tex[f.replace(/\.\w+$/, '')] = `data:${mime};base64,` + fs.readFileSync(path.join(here, 'assets', f)).toString('base64');
|
||||
}
|
||||
const texScript = `<script>window.__GE_TEX=${JSON.stringify(tex)};</script>`;
|
||||
const appScript = `<script>\n(() => {\n'use strict';\n${js}\n})();\n</script>`;
|
||||
let out = tpl
|
||||
.replace('<!--@CSS-->', () => `<style>\n${css}\n</style>`)
|
||||
.replace('<!--@TEX-->', () => texScript)
|
||||
.replace('<!--@JS-->', () => appScript);
|
||||
fs.writeFileSync(path.join(here, 'index.html'), out);
|
||||
const kb = (Buffer.byteLength(out) / 1024).toFixed(0);
|
||||
console.log(`built index.html ${kb} KB (${files.length} js files, ${Object.keys(tex).length} textures, ${Date.now() - t0} ms)`);
|
||||
}
|
||||
|
||||
build();
|
||||
if (process.argv.includes('--watch')) {
|
||||
let timer;
|
||||
for (const d of ['src', 'src/js', 'assets'])
|
||||
fs.watch(path.join(here, d), () => { clearTimeout(timer); timer = setTimeout(() => { try { build(); } catch (e) { console.error(e); } }, 80); });
|
||||
console.log('watching…');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
// One-time texture prep: NASA public-domain sources -> small WebP files in ./assets.
|
||||
// Usage: node mobile/vision/prep-textures.mjs <dir-with-downloads>
|
||||
// Sources (public domain, NASA): Blue Marble NG (cloudless day), Black Marble 2016 (night lights,
|
||||
// https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km.jpg),
|
||||
// Blue Marble clouds, land/water mask and topography ([email protected] example images).
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const require = createRequire(import.meta.url);
|
||||
const sharp = require('sharp');
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = process.argv[2];
|
||||
if (!src) { console.error('usage: node prep-textures.mjs <download-dir>'); process.exit(1); }
|
||||
const out = (f) => path.join(here, 'assets', f);
|
||||
const jobs = [
|
||||
['earth-blue-marble.jpg', 'day.webp', (s) => s.resize(4096, 2048).webp({ quality: 74, smartSubsample: true, effort: 6 })],
|
||||
['BlackMarble_2016_3km.jpg', 'night.webp', (s) => s.resize(4096, 2048, { kernel: 'lanczos3' }).webp({ quality: 74, smartSubsample: true, effort: 6 })],
|
||||
['clouds.png', 'clouds.webp', (s) => s.resize(2048, 1024).extractChannel('alpha').webp({ quality: 66, effort: 6 })],
|
||||
['earth-water.png', 'water.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 60, effort: 6 })],
|
||||
['earth-topology.png', 'topo.webp', (s) => s.resize(2048, 1024).greyscale().webp({ quality: 74, effort: 6 })],
|
||||
];
|
||||
for (const [i, o, fn] of jobs) {
|
||||
const info = await fn(sharp(path.join(src, i))).toFile(out(o));
|
||||
console.log(o, info.width + 'x' + info.height, (info.size / 1024).toFixed(0) + ' KB');
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/* God's Eye — vision. Dark-first, Liquid-Glass-era shell. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #05070B; --bg2: #0A0E15; --card: #0C1118; --card2: #111723;
|
||||
--text: #F3F6FB; --text2: rgba(232,238,248,.68); --text3: rgba(232,238,248,.44); --text4: rgba(232,238,248,.26);
|
||||
--sep: rgba(255,255,255,.08); --stroke: rgba(255,255,255,.16);
|
||||
--sky: #7CC4FF; --sea: #38E1C6; --orbit: #C9A7FF; --haz: #FF6A3D; --eyes: #FFD166; --ground: #9BE15D; --wx: #8AB4F8; --infra: #B8C0CC;
|
||||
--gold: #E9C46A; --gold2: #F6DFA0; --live: #FF453A; --ok: #30D158; --warn: #FFD60A; --ai: #B79CFF;
|
||||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro", "Inter", system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
--display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter Display", "Inter", system-ui, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, "DejaVu Sans Mono", monospace;
|
||||
--sat: env(safe-area-inset-top, 0px); --sab: env(safe-area-inset-bottom, 0px); --sal: env(safe-area-inset-left, 0px); --sar: env(safe-area-inset-right, 0px);
|
||||
--paneW: 0px; --tabH: 62px; --spring: cubic-bezier(.2,.9,.25,1.12); --out: cubic-bezier(.16,1,.3,1);
|
||||
--glass: rgba(24,29,38,.46); --glass-strong: rgba(16,20,28,.72);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); overflow: hidden; overscroll-behavior: none; }
|
||||
body { font: 15px/1.38 var(--font); color: var(--text); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; text-rendering: optimizeLegibility; font-feature-settings: "ss01", "cv11"; }
|
||||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
|
||||
input, textarea { font: inherit; color: inherit; }
|
||||
.num, .tnum { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
|
||||
.mono { font-family: var(--mono); font-size: .92em; letter-spacing: -.01em; }
|
||||
.ic { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; overflow: visible; }
|
||||
.ic.f { fill: currentColor; stroke: none; }
|
||||
.ic.sm { width: 16px; height: 16px; stroke-width: 1.8; } .ic.xs { width: 13px; height: 13px; stroke-width: 2; } .ic.lg { width: 28px; height: 28px; }
|
||||
.hide { display: none !important; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
|
||||
/* ---------- glass ---------- */
|
||||
.glass { position: relative; background: linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.025) 60%), var(--glass); -webkit-backdrop-filter: blur(24px) saturate(1.6); backdrop-filter: blur(24px) saturate(1.6);
|
||||
box-shadow: inset 0 .5px 0 rgba(255,255,255,.34), inset 0 0 0 .5px rgba(255,255,255,.10), inset 0 -.5px 0 rgba(255,255,255,.05); }
|
||||
.glass::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: radial-gradient(120% 60% at 20% -10%, rgba(255,255,255,.14), transparent 55%); mix-blend-mode: screen; }
|
||||
.glass.strong { background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.02) 60%), var(--glass-strong); }
|
||||
.pill { border-radius: 999px; }
|
||||
.circ { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; }
|
||||
|
||||
/* ---------- app & canvas ---------- */
|
||||
.app { position: fixed; inset: 0; overflow: hidden; background: var(--bg); }
|
||||
#gl { position: absolute; left: var(--paneW); top: 0; width: calc(100% - var(--paneW)); height: 100%; display: block; touch-action: none; outline: none; }
|
||||
.globe-area { position: absolute; left: var(--paneW); top: 0; right: 0; bottom: 0; pointer-events: none; }
|
||||
.labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.lbl { position: absolute; left: 0; top: 0; transform: translate(-50%, -100%); pointer-events: auto; display: flex; align-items: center; gap: 6px; padding: 5px 10px 5px 7px; font-size: 12px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; border-radius: 999px; color: var(--text); transition: opacity .25s; }
|
||||
.lbl i { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--sky)); box-shadow: 0 0 10px var(--c, var(--sky)); flex: none; }
|
||||
.lbl small { font-weight: 500; color: var(--text2); font-size: 11px; }
|
||||
.lbl.sel { box-shadow: inset 0 0 0 1px var(--gold), inset 0 .5px 0 rgba(255,255,255,.3); }
|
||||
.lbl.plain { background: none; backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none; text-shadow: 0 1px 6px rgba(0,0,0,.9); padding: 2px 4px; font-weight: 600; }
|
||||
.lbl.plain::after { display: none; }
|
||||
.lbl.tag { transform: translate(10px, -50%); }
|
||||
|
||||
/* ---------- views ---------- */
|
||||
.view { position: absolute; inset: 0; overflow: hidden auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: none; scrollbar-width: none; }
|
||||
.view::-webkit-scrollbar { display: none; }
|
||||
.view.on { display: block; animation: viewIn .38s var(--out); }
|
||||
@keyframes viewIn { from { opacity: 0; transform: translateY(8px) scale(.995); } }
|
||||
.pane { position: absolute; left: 0; top: 0; bottom: 0; width: 100%; }
|
||||
|
||||
/* large-title nav */
|
||||
.navbar { position: sticky; top: 0; z-index: 5; height: calc(var(--sat) + 48px); padding-top: var(--sat); display: grid; place-items: center; font-weight: 600; font-size: 17px; opacity: 0; transition: opacity .2s; pointer-events: none; margin-bottom: calc(-48px - var(--sat)); }
|
||||
.navbar.on { opacity: 1; pointer-events: auto; }
|
||||
.navbar::before { content: ""; position: absolute; inset: 0; background: rgba(5,7,11,.62); -webkit-backdrop-filter: blur(20px) saturate(1.5); backdrop-filter: blur(20px) saturate(1.5); border-bottom: .5px solid var(--sep); z-index: -1; }
|
||||
.lt { padding: calc(var(--sat) + 14px) 20px 6px; }
|
||||
.lt .kicker { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--text3); display: flex; gap: 8px; align-items: center; }
|
||||
.lt h1 { margin: 2px 0 0; font: 700 34px/41px var(--display); letter-spacing: .2px; }
|
||||
.lt .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
|
||||
.avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700; color: #fff; background: conic-gradient(from 200deg, hsl(var(--h,205) 80% 55%), hsl(calc(var(--h,205) + 60) 75% 45%), hsl(var(--h,205) 80% 55%)); box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.18); flex: none; }
|
||||
.avatar.sm { width: 22px; height: 22px; font-size: 9px; } .avatar.md { width: 28px; height: 28px; font-size: 11px; } .avatar.xl { width: 72px; height: 72px; font-size: 26px; }
|
||||
.avatar.ai { background: radial-gradient(circle at 30% 30%, #E6DBFF, #8E6CFF 55%, #4A2FB8); }
|
||||
.stack { display: flex; } .stack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px var(--bg), inset 0 0 0 1.5px rgba(255,255,255,.18); } .stack .avatar:first-child { margin-left: 0; }
|
||||
|
||||
.livebar { position: relative; z-index: 1; display: flex; gap: 14px; align-items: center; font-size: 13px; color: var(--text2); padding: 4px 20px 0; flex-wrap: wrap; }
|
||||
.livebar b { color: var(--text); font-weight: 600; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--live); display: inline-block; flex: none; }
|
||||
.dot.pulse { animation: pulse 1.8s infinite; }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,69,58,.55); } 70% { box-shadow: 0 0 0 7px rgba(255,69,58,0); } 100% { box-shadow: 0 0 0 0 rgba(255,69,58,0); } }
|
||||
.chips { display: flex; gap: 8px; overflow-x: auto; padding: 12px 20px 6px; scrollbar-width: none; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip { flex: none; height: 34px; padding: 0 14px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); transition: background .2s, color .2s; white-space: nowrap; }
|
||||
.chip.on { background: var(--text); color: #05070B; }
|
||||
.chip .n { font-weight: 500; opacity: .6; }
|
||||
|
||||
/* ---------- Now ---------- */
|
||||
#v-now { background: transparent; }
|
||||
.now-hero { height: calc(var(--sat) + clamp(290px, 41vh, 400px)); position: relative; display: flex; flex-direction: column; justify-content: flex-end; pointer-events: none; }
|
||||
.now-hero > * { pointer-events: auto; }
|
||||
.now-hero::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 90px; background: linear-gradient(to bottom, rgba(5,7,11,0), var(--bg)); pointer-events: none; }
|
||||
.now-hero .lt { position: relative; z-index: 1; padding-bottom: 0; text-shadow: 0 2px 18px rgba(0,0,0,.6); }
|
||||
.now-top { position: absolute; top: calc(var(--sat) + 10px); left: 16px; right: 16px; display: flex; justify-content: space-between; align-items: center; z-index: 2; }
|
||||
.now-body { background: var(--bg); min-height: 60vh; padding-bottom: calc(var(--sab) + 150px); }
|
||||
.sec-h { display: flex; align-items: baseline; justify-content: space-between; padding: 18px 20px 10px; }
|
||||
.sec-h h2 { margin: 0; font: 700 22px/28px var(--display); letter-spacing: .1px; }
|
||||
.sec-h span { color: var(--text3); font-size: 13px; }
|
||||
.masonry { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 12px; align-items: start; }
|
||||
.masonry .col { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.card { position: relative; border-radius: 18px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; transform: translateZ(0); transition: transform .35s var(--spring); }
|
||||
.card:active { transform: scale(.975); }
|
||||
.card .thumb { position: relative; width: 100%; background: #070A10; overflow: hidden; }
|
||||
.card .thumb canvas, .card .thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.card .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.28), transparent 26%, transparent 55%, rgba(0,0,0,.66)); pointer-events: none; }
|
||||
.card .tl { position: absolute; top: 9px; left: 9px; z-index: 2; display: flex; gap: 6px; }
|
||||
.badge { height: 22px; padding: 0 8px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: rgba(10,12,18,.5); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); }
|
||||
.badge.live { color: #fff; } .badge.live .dot { width: 6px; height: 6px; }
|
||||
.badge.fam i { width: 7px; height: 7px; border-radius: 50%; background: var(--c); box-shadow: 0 0 8px var(--c); }
|
||||
.badge.d4 { color: var(--gold2); } .badge.replay { color: var(--text2); }
|
||||
.card .tr { position: absolute; top: 9px; right: 9px; z-index: 2; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: rgba(10,12,18,.42); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18); color: var(--c); }
|
||||
.card .bl { position: absolute; left: 11px; right: 11px; bottom: 9px; z-index: 2; }
|
||||
.card .metric { font: 700 21px/1.05 var(--display); letter-spacing: -.01em; text-shadow: 0 1px 10px rgba(0,0,0,.6); }
|
||||
.card .metric small { font-size: 12px; font-weight: 600; color: var(--text2); margin-left: 3px; letter-spacing: 0; }
|
||||
.card .mlabel { font-size: 11.5px; color: var(--text2); margin-top: 2px; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
|
||||
.card .meta { padding: 9px 11px 11px; }
|
||||
.card .ttl { font-size: 14.5px; font-weight: 600; line-height: 1.28; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card .src { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 7px; font-size: 11.5px; color: var(--text3); }
|
||||
.card .src .l { display: flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.card .src .r { display: flex; align-items: center; gap: 4px; flex: none; }
|
||||
.card .cta { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px; background: rgba(155,225,93,.14); color: var(--ground); font-size: 12.5px; font-weight: 700; }
|
||||
.card.flash .thumb { animation: flash 1.2s ease; }
|
||||
@keyframes flash { 30% { filter: brightness(1.35) saturate(1.2); } }
|
||||
.card .spark { position: absolute; right: 10px; bottom: 12px; z-index: 2; opacity: .9; }
|
||||
|
||||
/* ---------- tab bar & accessory ---------- */
|
||||
.tabwrap { position: absolute; left: 0; width: var(--tabW, 100%); bottom: 0; padding: 0 12px calc(var(--sab) + 10px); display: flex; gap: 10px; align-items: flex-end; justify-content: center; z-index: 40; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
|
||||
.tabwrap > * { pointer-events: auto; }
|
||||
.tabbar { height: var(--tabH); border-radius: 31px; display: flex; align-items: center; padding: 0 4px; gap: 0; transition: width .45s var(--out); }
|
||||
.tab { position: relative; z-index: 1; width: 66px; height: 54px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-size: 10.5px; font-weight: 600; color: var(--text2); border-radius: 27px; transition: color .25s; letter-spacing: .01em; }
|
||||
.tab .ic { width: 24px; height: 24px; }
|
||||
.tab.on { color: var(--sky); }
|
||||
.tablens { position: absolute; z-index: 0; top: 4px; height: 54px; width: 66px; border-radius: 27px; background: rgba(255,255,255,.12); box-shadow: inset 0 .5px 0 rgba(255,255,255,.3), inset 0 0 0 .5px rgba(255,255,255,.1); transition: transform .5s var(--spring), width .3s; }
|
||||
.searchbtn { width: var(--tabH); height: var(--tabH); border-radius: 50%; display: grid; place-items: center; flex: none; }
|
||||
.searchbtn .ic { width: 24px; height: 24px; }
|
||||
.tabwrap.mini .tab:not(.on) { width: 0; opacity: 0; overflow: hidden; padding: 0; }
|
||||
.tabwrap.mini .tab.on { width: 54px; }
|
||||
.tabwrap.mini .tab.on span { display: none; }
|
||||
.tabwrap.mini .tablens { width: 54px; }
|
||||
.tabwrap.mini .tabbar { width: auto; }
|
||||
.tabwrap.hidden { transform: translateY(140%); opacity: 0; pointer-events: none; }
|
||||
.tabwrap.hidden > * { pointer-events: none; }
|
||||
.accessory { height: 48px; border-radius: 24px; display: flex; align-items: center; gap: 10px; padding: 0 6px 0 14px; min-width: 0; flex: 1; max-width: 520px; transition: all .45s var(--out); }
|
||||
.acc-host { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); display: flex; justify-content: center; z-index: 39; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
|
||||
.acc-host > * { pointer-events: auto; }
|
||||
.acc-host.hidden { opacity: 0; transform: translateY(30px); pointer-events: none; }
|
||||
.acc-host.hidden > * { pointer-events: none; }
|
||||
.accessory .ttl { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.accessory .sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.accessory .txt { min-width: 0; flex: 1; line-height: 1.2; }
|
||||
.accessory .ab { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.tabwrap .accessory { display: none; }
|
||||
.tabwrap.mini .accessory { display: flex; }
|
||||
|
||||
/* ---------- globe chrome ---------- */
|
||||
.gtop { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); right: 12px; display: flex; justify-content: space-between; align-items: flex-start; z-index: 20; pointer-events: none; }
|
||||
.gtop > * { pointer-events: auto; }
|
||||
.status { height: 40px; padding: 0 14px 0 12px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
|
||||
.status .t { color: var(--text2); font-weight: 500; }
|
||||
.gbtns { display: flex; flex-direction: column; gap: 10px; }
|
||||
.gbtn { width: 44px; height: 44px; border-radius: 22px; display: grid; place-items: center; }
|
||||
.gbtn.on { color: var(--sky); }
|
||||
.compass .ic { transition: transform .1s linear; }
|
||||
.gbottom { position: absolute; left: calc(var(--paneW) + 0px); right: 0; bottom: calc(var(--sab) + 10px + var(--tabH) + 10px); z-index: 20; display: flex; flex-direction: column; gap: 10px; padding: 0 12px; pointer-events: none; transition: transform .45s var(--out), opacity .3s; }
|
||||
.gbottom > * { pointer-events: auto; }
|
||||
.dock { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 2px; margin: -2px; }
|
||||
.dock::-webkit-scrollbar { display: none; }
|
||||
.fam { flex: none; height: 38px; padding: 0 13px 0 10px; border-radius: 19px; display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 600; color: var(--text); transition: opacity .2s, transform .3s var(--spring); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
||||
.fam .ic { color: var(--c); width: 18px; height: 18px; }
|
||||
.fam .n { color: var(--text3); font-weight: 500; font-size: 12px; }
|
||||
.fam.off { opacity: .5; } .fam.off .ic { color: var(--text3); }
|
||||
.fam.solo { box-shadow: inset 0 0 0 1.5px var(--c); }
|
||||
.fam:active { transform: scale(.94); }
|
||||
|
||||
/* time ribbon */
|
||||
.ribbon { height: 62px; border-radius: 22px; position: relative; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
|
||||
.ribbon canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.ribbon .head { position: absolute; left: 50%; top: 6px; bottom: 6px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--text); box-shadow: 0 0 12px rgba(255,255,255,.6); pointer-events: none; z-index: 2; }
|
||||
.ribbon .readout { position: absolute; left: 12px; top: 7px; font-size: 12px; font-weight: 600; z-index: 3; pointer-events: none; display: flex; gap: 6px; align-items: center; }
|
||||
.ribbon .readout .rel { color: var(--text3); font-weight: 500; }
|
||||
.ribbon .nowbtn { position: absolute; right: 6px; top: 6px; height: 26px; padding: 0 10px; border-radius: 13px; font-size: 11.5px; font-weight: 800; letter-spacing: .06em; z-index: 3; display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,.08); color: var(--text2); transition: all .25s; }
|
||||
.ribbon .nowbtn.live { background: rgba(255,69,58,.18); color: #FF8A80; }
|
||||
.ribbon .fc { position: absolute; right: 74px; top: 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: var(--wx); z-index: 3; opacity: 0; transition: opacity .2s; }
|
||||
.ribbon.future .fc { opacity: 1; }
|
||||
.ribbon.breath::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px rgba(255,69,58,.0); animation: breath 3.2s ease-in-out infinite; pointer-events: none; z-index: 1; }
|
||||
@keyframes breath { 50% { box-shadow: inset 0 0 0 1px rgba(255,120,110,.32), inset 0 0 18px rgba(255,69,58,.12); } }
|
||||
|
||||
/* pick card */
|
||||
.pick { border-radius: 26px; padding: 12px 12px 12px 14px; display: none; flex-direction: column; gap: 10px; animation: rise .4s var(--spring); }
|
||||
.pick.on { display: flex; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(16px) scale(.98); } }
|
||||
.pick .hd { display: flex; align-items: center; gap: 11px; }
|
||||
.pick .ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); flex: none; }
|
||||
.pick .tt { flex: 1; min-width: 0; } .pick .tt b { display: block; font-size: 16px; } .pick .tt span { font-size: 12.5px; color: var(--text2); }
|
||||
.pick .x { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.08); color: var(--text2); }
|
||||
.kv { display: flex; gap: 18px; font-size: 12px; color: var(--text3); }
|
||||
.kv b { display: block; color: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.acts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.act { height: 52px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; font-size: 11px; font-weight: 600; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.act .ic { width: 20px; height: 20px; }
|
||||
.act.pri { background: rgba(124,196,255,.18); color: var(--sky); }
|
||||
.act.gold { background: rgba(233,196,106,.16); color: var(--gold2); }
|
||||
.act:active { transform: scale(.95); }
|
||||
|
||||
/* layer grid (Control Center) */
|
||||
.cc { position: absolute; inset: 0; z-index: 95; display: none; background: rgba(3,5,8,.38); -webkit-backdrop-filter: blur(30px) saturate(1.4) brightness(.8); backdrop-filter: blur(30px) saturate(1.4) brightness(.8); overflow: auto; padding: calc(var(--sat) + 14px) 16px calc(var(--sab) + 40px); }
|
||||
.cc.on { display: block; animation: ccIn .42s var(--out); }
|
||||
@keyframes ccIn { from { opacity: 0; transform: scale(1.04); } }
|
||||
.cc .cch { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.cc .cch h2 { margin: 0; font: 700 28px/34px var(--display); }
|
||||
.cc .cch p { margin: 2px 0 0; color: var(--text3); font-size: 13px; }
|
||||
.lenses { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 6px 0 14px; }
|
||||
.lens { border-radius: 16px; padding: 8px 4px 7px; display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.lens .sw { width: 100%; height: 38px; border-radius: 11px; }
|
||||
.lens.on { color: var(--text); box-shadow: inset 0 0 0 1.5px var(--sky); }
|
||||
.ccfam { margin-top: 14px; }
|
||||
.ccfam .fh { display: flex; align-items: center; gap: 8px; margin: 0 2px 8px; font-size: 13px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text2); user-select: none; -webkit-user-select: none; }
|
||||
.ccfam .fh i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 10px var(--c); }
|
||||
.ccfam .fh .solo { margin-left: auto; font-size: 11px; font-weight: 700; color: var(--text3); letter-spacing: .04em; padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,.06); }
|
||||
.ccgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.tile { border-radius: 20px; padding: 11px 12px; display: flex; gap: 10px; align-items: center; min-height: 64px; background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); text-align: left; transition: background .2s, transform .2s var(--spring); }
|
||||
.tile:active { transform: scale(.96); }
|
||||
.tile .ti { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.1); color: var(--text2); flex: none; transition: all .2s; }
|
||||
.tile .ti .ic { width: 19px; height: 19px; }
|
||||
.tile .tn { font-size: 13px; font-weight: 600; line-height: 1.2; }
|
||||
.tile .tc { font-size: 12px; color: var(--text3); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
|
||||
.tile .tc .st { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
|
||||
.tile .tc .st.stale { background: transparent; box-shadow: inset 0 0 0 1px var(--text3); }
|
||||
.tile.on { background: rgba(255,255,255,.92); color: #0A0D12; }
|
||||
.tile.on .ti { background: var(--c); color: #071018; }
|
||||
.tile.on .tc { color: rgba(10,13,18,.55); }
|
||||
|
||||
/* ---------- sheet ---------- */
|
||||
.sheet { position: absolute; left: 0; right: 0; bottom: 0; height: calc(100% - var(--sat) - 8px); z-index: 30; border-radius: 30px 30px 0 0; transform: translateY(100%); transition: transform .5s var(--out); display: flex; flex-direction: column; will-change: transform; touch-action: none; }
|
||||
.sheet.drag { transition: none; }
|
||||
.sheet .grab { flex: none; height: 26px; display: grid; place-items: center; cursor: grab; }
|
||||
.sheet .grab::before { content: ""; width: 38px; height: 5px; border-radius: 3px; background: rgba(255,255,255,.3); }
|
||||
.sheet .sc { flex: 1; overflow: hidden auto; padding: 0 18px calc(var(--sab) + 30px); overscroll-behavior: contain; touch-action: pan-y; scrollbar-width: none; }
|
||||
.sheet .sc::-webkit-scrollbar { display: none; }
|
||||
.sh-hd { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.sh-hd .t { flex: 1; min-width: 0; }
|
||||
.sh-hd h2 { margin: 6px 0 2px; font: 700 23px/28px var(--display); letter-spacing: -.005em; }
|
||||
.sh-hd .pl { color: var(--text2); font-size: 13.5px; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; margin: 14px 0 4px; }
|
||||
.metric { padding: 0 6px 0 0; }
|
||||
.metric .v { font: 700 26px/1.05 var(--display); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
|
||||
.metric .v small { font-size: 13px; font-weight: 600; color: var(--text2); margin-left: 2px; letter-spacing: 0; }
|
||||
.metric .l { font-size: 12px; color: var(--text2); margin-top: 3px; }
|
||||
.metric .s { font-size: 10.5px; color: var(--text3); margin-top: 2px; }
|
||||
.sh-acts { margin: 14px 0 6px; }
|
||||
.brief { margin-top: 16px; }
|
||||
.brief p { margin: 0 0 12px; font-size: 15px; line-height: 1.5; color: rgba(240,244,250,.86); }
|
||||
.brief h3 { margin: 22px 0 8px; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
|
||||
.sline { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; padding: 9px 0; border-top: .5px solid var(--sep); }
|
||||
.sline .lab { font-size: 13.5px; font-weight: 600; }
|
||||
.sline .val { font-size: 13.5px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.sline svg { grid-column: 1 / -1; width: 100%; height: 46px; overflow: visible; }
|
||||
.sline .src { grid-column: 1 / -1; font-size: 11px; color: var(--text3); display: flex; justify-content: space-between; }
|
||||
.multi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.multi figure { margin: 0; }
|
||||
.multi canvas, .multi svg { width: 100%; aspect-ratio: 1; border-radius: 10px; display: block; background: #070A10; }
|
||||
.multi figcaption { font-size: 11px; color: var(--text2); margin-top: 4px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.multi figcaption b { color: var(--text); }
|
||||
.evid { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 -18px; padding: 0 18px; }
|
||||
.evid::-webkit-scrollbar { display: none; }
|
||||
.ev { flex: none; width: 116px; border-radius: 14px; overflow: hidden; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.ev .im { height: 84px; position: relative; background-size: cover; background-position: center; }
|
||||
.ev .im .seal { position: absolute; right: 6px; bottom: 6px; }
|
||||
.ev .cap { padding: 6px 8px 8px; font-size: 11px; color: var(--text2); line-height: 1.3; }
|
||||
.ev .cap b { color: var(--text); font-weight: 600; display: block; }
|
||||
.srcs { font-size: 12.5px; }
|
||||
.srcs .r { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; padding: 8px 0; border-top: .5px solid var(--sep); }
|
||||
.srcs .r .st { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
|
||||
.srcs .r .st.stale { background: none; box-shadow: inset 0 0 0 1.2px var(--text3); }
|
||||
.srcs .r span { color: var(--text3); font-variant-numeric: tabular-nums; }
|
||||
.backbtn { position: absolute; top: calc(var(--sat) + 10px); left: calc(var(--paneW) + 12px); z-index: 35; display: none; }
|
||||
.backbtn.on { display: grid; }
|
||||
.sitbtns { position: absolute; top: calc(var(--sat) + 10px); right: 12px; z-index: 35; display: none; gap: 10px; }
|
||||
.sitbtns.on { display: flex; }
|
||||
.seal { width: 22px; height: 22px; color: var(--gold); filter: drop-shadow(0 0 6px rgba(233,196,106,.6)); }
|
||||
|
||||
/* morph */
|
||||
.morph { position: absolute; z-index: 50; overflow: hidden; pointer-events: none; display: none; will-change: transform, border-radius, opacity; }
|
||||
.morph canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.morph.on { display: block; }
|
||||
|
||||
/* ---------- ride HUD ---------- */
|
||||
.ride { position: absolute; inset: 0; z-index: 45; display: none; pointer-events: none; }
|
||||
.ride.on { display: block; animation: viewIn .5s var(--out); }
|
||||
.ride > * { pointer-events: auto; }
|
||||
.ride .top { position: absolute; top: calc(var(--sat) + 10px); left: 12px; right: 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.ride .top .who { flex: 1; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 14px; font-size: 13px; }
|
||||
.ride .top .who b { font-size: 14px; }
|
||||
.hud { position: absolute; inset: 0; pointer-events: none !important; font-family: var(--mono); color: rgba(190,255,200,.9); text-shadow: 0 0 6px rgba(80,255,140,.5); }
|
||||
.hud svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.hud .tape { position: absolute; top: 50%; transform: translateY(-50%); font-size: 15px; font-weight: 600; padding: 6px 9px; border: 1px solid rgba(190,255,200,.55); border-radius: 6px; background: rgba(0,20,8,.25); }
|
||||
.hud .tape small { display: block; font-size: 9.5px; opacity: .7; font-weight: 500; }
|
||||
.chat { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 72px); height: 46%; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; pointer-events: none !important; mask-image: linear-gradient(to top, #000 70%, transparent); -webkit-mask-image: linear-gradient(to top, #000 70%, transparent); }
|
||||
.bub { align-self: flex-start; max-width: 84%; display: flex; gap: 8px; align-items: flex-end; animation: bubIn .5s var(--spring); }
|
||||
.bub .bx { padding: 8px 12px; border-radius: 18px 18px 18px 6px; font-size: 13.5px; line-height: 1.35; }
|
||||
.bub .bx b { display: block; font-size: 11.5px; color: var(--text2); font-weight: 600; margin-bottom: 1px; }
|
||||
@keyframes bubIn { from { opacity: 0; transform: translateY(20px) scale(.96); } }
|
||||
.react { position: absolute; right: 16px; bottom: calc(var(--sab) + 130px); width: 40px; height: 300px; pointer-events: none !important; }
|
||||
.react span { position: absolute; bottom: 0; left: 8px; animation: floatUp 2.6s ease-out forwards; color: var(--live); }
|
||||
@keyframes floatUp { to { transform: translate(var(--dx, 0px), -280px) scale(1.2); opacity: 0; } }
|
||||
.compose { position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab) + 12px); display: flex; gap: 8px; }
|
||||
.compose .in { flex: 1; height: 48px; border-radius: 24px; display: flex; align-items: center; padding: 0 16px; color: var(--text3); font-size: 14px; }
|
||||
.compose .in input { flex: 1; background: none; border: 0; outline: none; color: var(--text); font-size: 15px; min-width: 0; }
|
||||
|
||||
/* ---------- capture ---------- */
|
||||
#v-capture { background: #000; z-index: 25; }
|
||||
#v-capture.on { display: block; }
|
||||
.cap-ui { position: absolute; inset: 0; pointer-events: none; }
|
||||
.cap-ui > * { pointer-events: auto; }
|
||||
.cap-top { position: absolute; top: calc(var(--sat) + 8px); left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.cap-top .st { height: 34px; padding: 0 12px; border-radius: 17px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 7px; }
|
||||
.compass-tape { position: absolute; top: calc(var(--sat) + 52px); left: 0; right: 0; height: 40px; pointer-events: none !important; }
|
||||
.callouts { position: absolute; inset: 0; pointer-events: none !important; }
|
||||
.callout { position: absolute; left: 0; top: 0; pointer-events: auto; display: flex; align-items: center; gap: 8px; padding: 6px 11px 6px 8px; border-radius: 14px; font-size: 12px; transform: translate(14px, -50%); white-space: nowrap; }
|
||||
.callout b { font-size: 13px; display: block; }
|
||||
.callout span { color: var(--text2); font-size: 11px; }
|
||||
.callout .ci { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 22%, transparent); color: var(--c); }
|
||||
.reticle { position: absolute; width: 34px; height: 34px; margin: -17px 0 0 -17px; border: 1.5px solid var(--c, #fff); border-radius: 10px; pointer-events: none; box-shadow: 0 0 12px var(--c); }
|
||||
.cap-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 16px calc(var(--sab) + 18px); display: flex; flex-direction: column; align-items: center; gap: 14px; background: linear-gradient(to top, rgba(0,0,0,.55), transparent); }
|
||||
.zooms { display: flex; gap: 6px; padding: 4px; border-radius: 22px; }
|
||||
.zooms button { width: 36px; height: 36px; border-radius: 50%; font-size: 12px; font-weight: 700; color: var(--text2); }
|
||||
.zooms button.on { background: rgba(255,255,255,.14); color: var(--warn); }
|
||||
.dial { display: flex; gap: 22px; font-size: 13px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text2); transition: transform .45s var(--spring); }
|
||||
.dial button { padding: 6px 2px; }
|
||||
.dial button.on { color: var(--warn); }
|
||||
.shutrow { width: 100%; max-width: 440px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.shutter { width: 78px; height: 78px; border-radius: 50%; display: grid; place-items: center; box-shadow: 0 0 0 4px rgba(255,255,255,.92); transition: transform .2s; }
|
||||
.shutter i { width: 64px; height: 64px; border-radius: 50%; background: #fff; transition: all .25s var(--spring); }
|
||||
.shutter.look i { background: transparent; box-shadow: inset 0 0 0 2px rgba(255,255,255,.7); }
|
||||
.shutter:active i { transform: scale(.88); }
|
||||
.lastcap { width: 48px; height: 48px; border-radius: 12px; background: #111 center/cover; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); position: relative; }
|
||||
.lastcap .seal { position: absolute; right: -6px; top: -6px; width: 20px; height: 20px; }
|
||||
.sensors { position: absolute; left: 12px; bottom: calc(var(--sab) + 210px); border-radius: 16px; padding: 8px 10px; font-size: 11px; display: none; gap: 3px; flex-direction: column; min-width: 158px; color: var(--text2); }
|
||||
.sensors.on { display: flex; }
|
||||
.sensors b { color: var(--text); font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.sensors svg { width: 140px; height: 26px; }
|
||||
.seal-ov { position: absolute; inset: 0; z-index: 70; display: none; align-items: center; justify-content: center; padding: calc(var(--sat) + 20px) 18px calc(var(--sab) + 20px); background: rgba(0,0,0,.55); -webkit-backdrop-filter: blur(18px) saturate(1.2); backdrop-filter: blur(18px) saturate(1.2); }
|
||||
.seal-ov.on { display: flex; animation: viewIn .35s var(--out); }
|
||||
.bundle { width: 100%; max-width: 420px; border-radius: 28px; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow: auto; }
|
||||
.bundle .ph { position: relative; border-radius: 18px; overflow: hidden; aspect-ratio: 4/3; background: #000; }
|
||||
.bundle .ph canvas, .bundle .ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.bigseal { position: absolute; right: 10px; bottom: 10px; width: 92px; height: 92px; opacity: 0; transform: scale(2.2) rotate(-40deg); transition: all .7s var(--spring); }
|
||||
.bigseal.on { opacity: 1; transform: scale(1) rotate(0); }
|
||||
.steps { display: flex; flex-direction: column; gap: 2px; }
|
||||
.step { display: grid; grid-template-columns: 24px 1fr; gap: 8px; padding: 7px 2px; border-top: .5px solid var(--sep); font-size: 13px; opacity: .35; transition: opacity .3s; }
|
||||
.step.on { opacity: 1; }
|
||||
.step .ck { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px var(--text3); color: transparent; transition: all .3s; }
|
||||
.step.done .ck { background: var(--gold); box-shadow: none; color: #1A1408; }
|
||||
.step b { font-weight: 600; } .step .h { font-family: var(--mono); font-size: 11px; color: var(--text2); word-break: break-all; line-height: 1.35; margin-top: 2px; }
|
||||
.step svg { width: 100%; height: 30px; margin-top: 4px; }
|
||||
.btnrow { display: flex; gap: 8px; }
|
||||
.btn { flex: 1; height: 50px; border-radius: 25px; font-weight: 700; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: rgba(255,255,255,.1); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.12); }
|
||||
.btn.gold { background: linear-gradient(180deg, #F3D68A, #D9AE4F); color: #1C1405; box-shadow: inset 0 .5px 0 rgba(255,255,255,.6); }
|
||||
.btn.pri { background: var(--text); color: #05070B; }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn.sm { height: 38px; font-size: 13.5px; flex: none; padding: 0 16px; }
|
||||
|
||||
/* ---------- Threads ---------- */
|
||||
.tcard { margin: 0 14px 14px; border-radius: 24px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; }
|
||||
.tcard .thumb { position: relative; aspect-ratio: 16/9; background: #070A10; }
|
||||
.tcard .thumb canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.tcard .thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgba(0,0,0,.7)); }
|
||||
.tcard .bl { position: absolute; left: 14px; bottom: 12px; right: 14px; z-index: 1; display: flex; justify-content: space-between; align-items: flex-end; gap: 10px; }
|
||||
.tcard .bl h3 { margin: 0; font: 700 20px/1.15 var(--display); }
|
||||
.tcard .bl p { margin: 3px 0 0; font-size: 12.5px; color: var(--text2); }
|
||||
.tcard .stats { display: flex; gap: 16px; padding: 12px 14px; font-size: 12px; color: var(--text3); align-items: center; }
|
||||
.tcard .stats b { color: var(--text); font-size: 15px; display: block; font-variant-numeric: tabular-nums; }
|
||||
.tcard .stats .stack { margin-left: auto; }
|
||||
.ws { position: absolute; inset: 0; display: none; z-index: 10; }
|
||||
.ws.on { display: block; animation: pushIn .45s var(--out); }
|
||||
@keyframes pushIn { from { transform: translateX(30%); opacity: 0; } }
|
||||
.ws .wsh { position: absolute; top: 0; left: 0; right: 0; padding: calc(var(--sat) + 10px) 12px 0; display: flex; align-items: center; gap: 10px; z-index: 3; pointer-events: none; }
|
||||
.ws .wsh > * { pointer-events: auto; }
|
||||
.ws .wsh .tt { flex: 1; min-width: 0; height: 44px; border-radius: 22px; display: flex; align-items: center; gap: 10px; padding: 0 8px 0 14px; }
|
||||
.ws .wsh .tt b { font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.ws .wsh .tt span { font-size: 11.5px; color: var(--text2); }
|
||||
.ws .body { position: absolute; left: 0; right: 0; bottom: 0; top: 40%; border-radius: 28px 28px 0 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.ws .seg { display: flex; margin: 12px 14px 8px; padding: 3px; border-radius: 12px; background: rgba(255,255,255,.07); flex: none; }
|
||||
.ws .seg button { flex: 1; height: 34px; border-radius: 9px; font-size: 13px; font-weight: 600; color: var(--text2); transition: all .25s; }
|
||||
.ws .seg button.on { background: rgba(255,255,255,.16); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,.12) inset; }
|
||||
.ws .wsc { flex: 1; overflow: auto; padding: 4px 14px calc(var(--sab) + 120px); scrollbar-width: none; }
|
||||
.ws .wsc::-webkit-scrollbar { display: none; }
|
||||
.stratum { font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 10px 2px 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.obs { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); }
|
||||
.obs .oi { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c) 18%, transparent); color: var(--c); }
|
||||
.obs .oi .ic { width: 18px; height: 18px; }
|
||||
.obs .ot { font-size: 14px; font-weight: 600; line-height: 1.3; }
|
||||
.obs .om { font-size: 11.5px; color: var(--text3); margin-top: 3px; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
||||
.obs .om .mono { color: var(--text2); }
|
||||
.obs .tagid { font-family: var(--mono); font-size: 11px; color: var(--gold2); background: rgba(233,196,106,.12); padding: 1px 6px; border-radius: 6px; }
|
||||
.hyp { border-radius: 20px; padding: 13px 14px; margin: 0 0 10px; background: var(--card2); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.hyp.ai { box-shadow: inset 0 0 0 1px rgba(183,156,255,.45); background: linear-gradient(180deg, rgba(183,156,255,.10), rgba(183,156,255,.02)), var(--card2); }
|
||||
.hyp .hh { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); }
|
||||
.hyp .hh b { color: var(--text); font-weight: 600; }
|
||||
.hyp .hh .stt { margin-left: auto; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; padding: 3px 8px; border-radius: 999px; background: rgba(48,209,88,.14); color: #6BE38A; }
|
||||
.hyp .hh .stt.disputed { background: rgba(255,214,10,.14); color: var(--warn); }
|
||||
.hyp .hh .stt.proposed { background: rgba(183,156,255,.16); color: var(--ai); }
|
||||
.hyp .hb { margin: 8px 0 10px; font-size: 15px; line-height: 1.42; }
|
||||
.conf { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; font-size: 11.5px; color: var(--text3); }
|
||||
.conf svg { width: 100%; height: 14px; }
|
||||
.cites { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
|
||||
.hyp .hf { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.vote { height: 34px; padding: 0 12px; border-radius: 17px; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; background: rgba(255,255,255,.07); color: var(--text2); }
|
||||
.vote.on { background: rgba(124,196,255,.18); color: var(--sky); }
|
||||
.vote.dis.on { background: rgba(255,214,10,.16); color: var(--warn); }
|
||||
.composer { position: absolute; left: 10px; right: 10px; bottom: calc(var(--sab) + 10px); border-radius: 26px; padding: 8px; display: flex; flex-direction: column; gap: 8px; z-index: 5; }
|
||||
.composer .sug { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
||||
.composer .sug::-webkit-scrollbar { display: none; }
|
||||
.composer .sug button { flex: none; height: 30px; padding: 0 11px; border-radius: 15px; font-size: 12px; font-weight: 600; background: rgba(183,156,255,.14); color: #D7C9FF; white-space: nowrap; }
|
||||
.composer .row { display: flex; gap: 8px; align-items: center; }
|
||||
.composer textarea { flex: 1; min-height: 40px; max-height: 96px; resize: none; background: rgba(255,255,255,.06); border: 0; outline: 0; border-radius: 20px; padding: 10px 14px; font-size: 14.5px; line-height: 1.35; color: var(--text); }
|
||||
.composer .send { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--ai); color: #140B33; flex: none; }
|
||||
.composer .send[disabled] { opacity: .5; }
|
||||
.narr { display: flex; flex-direction: column; gap: 0; }
|
||||
.beat { display: grid; grid-template-columns: 54px 1fr; gap: 10px; padding: 11px 2px; border-top: .5px solid var(--sep); opacity: .55; transition: opacity .3s; cursor: pointer; }
|
||||
.beat.on { opacity: 1; }
|
||||
.beat .bt { font-family: var(--mono); font-size: 12px; color: var(--text2); padding-top: 2px; }
|
||||
.beat.on .bt { color: var(--gold2); }
|
||||
.beat p { margin: 0; font-size: 14.5px; line-height: 1.42; }
|
||||
.playbar { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; }
|
||||
.playbtn { width: 50px; height: 50px; border-radius: 50%; display: grid; place-items: center; background: var(--text); color: #05070B; flex: none; }
|
||||
.prog { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,.12); position: relative; overflow: hidden; }
|
||||
.prog i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--gold); border-radius: 2px; }
|
||||
.ledger .le { border-top: .5px solid var(--sep); padding: 10px 2px; }
|
||||
.ledger .le .lh { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; cursor: pointer; }
|
||||
.ledger .le .ix { font-family: var(--mono); font-size: 11px; color: var(--text3); }
|
||||
.ledger .le .ty { font-size: 13px; font-weight: 600; }
|
||||
.ledger .le .ty span { color: var(--text3); font-weight: 500; }
|
||||
.ledger .le .hs { font-family: var(--mono); font-size: 11px; color: var(--text2); }
|
||||
.ledger .le .vk { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.ledger .le .vk.ok { background: rgba(48,209,88,.2); color: #6BE38A; } .ledger .le .vk.bad { background: rgba(255,69,58,.25); color: #FF8A80; }
|
||||
.ledger .le pre { margin: 8px 0 0 34px; font-family: var(--mono); font-size: 10.5px; line-height: 1.45; color: var(--text2); white-space: pre-wrap; word-break: break-all; background: rgba(255,255,255,.04); padding: 8px 10px; border-radius: 10px; display: none; }
|
||||
.ledger .le.open pre { display: block; }
|
||||
.ledger .chain { width: 2px; height: 10px; background: linear-gradient(var(--gold), transparent); margin-left: 12px; }
|
||||
.presence { display: flex; align-items: center; gap: 8px; }
|
||||
.presence .stack .avatar { box-shadow: 0 0 0 2px rgba(20,24,32,.9), inset 0 0 0 1.5px rgba(255,255,255,.2); }
|
||||
.presence .on { position: relative; }
|
||||
.presence .on::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 1.5px #10141c; }
|
||||
.toastp { position: absolute; left: 50%; top: calc(var(--sat) + 62px); transform: translateX(-50%); z-index: 80; height: 40px; padding: 0 16px; border-radius: 20px; display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; opacity: 0; transition: opacity .3s, transform .4s var(--spring); pointer-events: none; white-space: nowrap; }
|
||||
.toastp.on { opacity: 1; transform: translateX(-50%) translateY(4px); }
|
||||
|
||||
/* ---------- You ---------- */
|
||||
.you-h { display: flex; gap: 14px; align-items: center; padding: 6px 20px 4px; }
|
||||
.you-h h2 { margin: 0; font: 700 22px/1.2 var(--display); }
|
||||
.concept { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: 12px; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--gold2); background: rgba(233,196,106,.10); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.4); }
|
||||
.stats4 { display: grid; grid-template-columns: repeat(4, 1fr); margin: 14px 16px 4px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); }
|
||||
.stats4 div { padding: 12px 8px; text-align: center; font-size: 11.5px; color: var(--text3); }
|
||||
.stats4 b { display: block; font: 700 21px/1.1 var(--display); color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.grp { margin: 0 16px; border-radius: 18px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); overflow: hidden; }
|
||||
.row2 { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 52px; border-top: .5px solid var(--sep); font-size: 15px; }
|
||||
.row2:first-child { border-top: 0; }
|
||||
.row2 .ri { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 22%, transparent); color: var(--c, var(--sky)); flex: none; }
|
||||
.row2 .ri .ic { width: 18px; height: 18px; }
|
||||
.row2 .rt { flex: 1; min-width: 0; } .row2 .rt small { display: block; font-size: 12px; color: var(--text3); margin-top: 1px; }
|
||||
.row2 .rv { color: var(--text3); font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.sw2 { width: 51px; height: 31px; border-radius: 16px; background: rgba(120,120,128,.32); position: relative; flex: none; transition: background .25s; }
|
||||
.sw2::after { content: ""; position: absolute; left: 2px; top: 2px; width: 27px; height: 27px; border-radius: 50%; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.3); transition: transform .3s var(--spring); }
|
||||
.sw2.on { background: var(--ok); } .sw2.on::after { transform: translateX(20px); }
|
||||
.capgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px; }
|
||||
.capc { border-radius: 16px; overflow: hidden; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); cursor: pointer; }
|
||||
.capc .im { aspect-ratio: 4/3; background: #111 center/cover; position: relative; }
|
||||
.capc .im .seal { position: absolute; right: 8px; top: 8px; }
|
||||
.capc .cm { padding: 8px 10px 10px; font-size: 12px; color: var(--text3); }
|
||||
.capc .cm b { display: block; color: var(--text); font-size: 13px; font-weight: 600; margin-bottom: 2px; }
|
||||
.capc .cm .mono { font-size: 10.5px; }
|
||||
.radius { padding: 14px; }
|
||||
.radius input[type=range] { width: 100%; accent-color: var(--sky); }
|
||||
.radius svg { width: 100%; height: 120px; display: block; border-radius: 12px; background: #070A10; }
|
||||
.credits { padding: 14px; font-size: 12.5px; line-height: 1.5; color: var(--text2); }
|
||||
.credits b { color: var(--text); }
|
||||
.inspector { position: absolute; inset: 0; z-index: 75; display: none; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.45); }
|
||||
.inspector.on { display: flex; animation: viewIn .3s var(--out); }
|
||||
.inspector .panel { width: 100%; max-width: 560px; border-radius: 30px 30px 0 0; padding: 10px 18px calc(var(--sab) + 18px); max-height: 88%; overflow: auto; }
|
||||
|
||||
/* search */
|
||||
.search { position: absolute; inset: 0; z-index: 65; display: none; flex-direction: column; background: rgba(3,5,8,.5); -webkit-backdrop-filter: blur(28px) saturate(1.3); backdrop-filter: blur(28px) saturate(1.3); }
|
||||
.search.on { display: flex; animation: viewIn .3s var(--out); }
|
||||
.search .res { flex: 1; overflow: auto; padding: calc(var(--sat) + 16px) 16px 16px; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.search .grpH { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); margin: 14px 4px 6px; }
|
||||
.search .it { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 14px; font-size: 15px; }
|
||||
.search .it:active { background: rgba(255,255,255,.06); }
|
||||
.search .it .ri { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--c, var(--sky)) 18%, transparent); color: var(--c, var(--sky)); flex: none; }
|
||||
.search .it small { display: block; color: var(--text3); font-size: 12px; }
|
||||
.search .bar { display: flex; gap: 10px; padding: 10px 12px calc(var(--sab) + 12px); }
|
||||
.search .field { flex: 1; height: 52px; border-radius: 26px; display: flex; align-items: center; gap: 10px; padding: 0 16px; }
|
||||
.search .field input { flex: 1; background: none; border: 0; outline: 0; font-size: 17px; color: var(--text); min-width: 0; }
|
||||
|
||||
/* cold open */
|
||||
.cold { position: absolute; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding-bottom: calc(var(--sab) + 18vh); pointer-events: auto; transition: opacity 1s ease; }
|
||||
.cold.out { opacity: 0; pointer-events: none; }
|
||||
.cold .tag { font: 600 15px/1.4 var(--display); letter-spacing: .32em; text-transform: uppercase; color: rgba(255,255,255,.9); opacity: 0; transform: translateY(8px); transition: all 1.4s var(--out); text-align: center; padding: 0 20px; }
|
||||
.cold .word { font: 700 44px/1 var(--display); letter-spacing: -.01em; margin-bottom: 14px; opacity: 0; transition: opacity 1.6s ease, letter-spacing 2.4s var(--out); letter-spacing: .12em; }
|
||||
.cold.s1 .tag { opacity: 1; transform: none; }
|
||||
.cold.s2 .word { opacity: 1; letter-spacing: -.01em; }
|
||||
.cold .skip { position: absolute; top: calc(var(--sat) + 12px); right: 14px; height: 34px; padding: 0 14px; border-radius: 17px; font-size: 13px; font-weight: 600; color: var(--text2); }
|
||||
.cold .eye { width: 60px; height: 60px; margin-bottom: 18px; opacity: 0; transition: opacity 1.2s; color: #fff; }
|
||||
.cold.s2 .eye { opacity: .95; }
|
||||
.concept-tag { position: absolute; z-index: 90; left: 14px; top: calc(var(--sat) + 58px); pointer-events: none; font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: rgba(246,223,160,.72); padding: 3px 7px; border-radius: 6px; background: rgba(10,12,18,.35); box-shadow: inset 0 0 0 .5px rgba(233,196,106,.3); }
|
||||
.app[data-tab="now"] .concept-tag, .app[data-tab="threads"] .concept-tag, .app[data-tab="you"] .concept-tag { top: calc(var(--sat) + 14px); left: auto; right: 14px; }
|
||||
.app[data-tab="capture"] .concept-tag { top: calc(var(--sat) + 100px); left: 14px; }
|
||||
.app[data-mode="situation"] .concept-tag { left: auto; right: 14px; top: calc(var(--sat) + 62px); }
|
||||
|
||||
/* intro */
|
||||
.pane, .tabwrap, .acc-host, .concept-tag, .gchrome { transition: opacity .9s ease, transform .45s var(--out); }
|
||||
.app.intro .pane, .app.intro .tabwrap, .app.intro .acc-host, .app.intro .concept-tag, .app.intro .gchrome, .app.intro .labels { opacity: 0 !important; pointer-events: none !important; }
|
||||
.app.intro .pane * { pointer-events: none !important; }
|
||||
|
||||
/* tab visibility (phone) */
|
||||
.app[data-tab="now"] .gchrome, .app[data-tab="threads"] .gchrome, .app[data-tab="you"] .gchrome, .app[data-tab="capture"] .gchrome { display: none; }
|
||||
.app[data-mode="situation"] .gbottom .dock, .app[data-mode="ride"] .gchrome, .app[data-mode="ride"] .tabwrap, .app[data-mode="ride"] .acc-host { display: none; }
|
||||
.app[data-tab="globe"] .acc-host { display: none; }
|
||||
.app[data-mode="situation"] .gtop, .app[data-mode="workspace"] .gtop { opacity: 0; pointer-events: none; }
|
||||
.app[data-mode="situation"] .gtop > * { pointer-events: none; }
|
||||
.app[data-mode="situation"] .acc-host { display: none; }
|
||||
.wide-only { display: none !important; }
|
||||
|
||||
/* ---------- wide / foldable: two panes ---------- */
|
||||
@media (min-width: 600px) {
|
||||
:root { --paneW: clamp(360px, 40vw, 440px); }
|
||||
.pane { width: var(--paneW); background: var(--bg); border-right: .5px solid var(--sep); z-index: 22; }
|
||||
.tabwrap { --tabW: var(--paneW); }
|
||||
.acc-host { right: auto; width: calc(var(--paneW) - 24px); }
|
||||
.app[data-tab="globe"] .acc-host { display: flex; }
|
||||
.wide-only { display: block !important; }
|
||||
.app .gchrome { display: block !important; }
|
||||
.app .gchrome.flexy { display: flex !important; }
|
||||
.now-hero { height: calc(var(--sat) + 150px); }
|
||||
.now-hero::after { display: none; }
|
||||
.now-hero .lt { text-shadow: none; }
|
||||
.gbottom { bottom: 18px; padding: 0 16px; max-width: 760px; margin: 0 auto; left: var(--paneW); }
|
||||
.sheet { left: 0; width: var(--paneW); height: 100%; border-radius: 0; transform: translateX(-104%); z-index: 34; background: var(--bg) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: inset -.5px 0 0 var(--sep) !important; }
|
||||
.sheet::after { display: none; }
|
||||
.sheet.wopen { transform: none !important; }
|
||||
.sheet .grab { display: none; }
|
||||
.sheet .sc { padding-top: calc(var(--sat) + 60px); }
|
||||
.backbtn { left: 12px; }
|
||||
.app[data-mode="situation"] .tabwrap { display: none; }
|
||||
.ws .body { top: 0; border-radius: 0; background: var(--bg) !important; backdrop-filter: none !important; }
|
||||
.ws .body::after { display: none; }
|
||||
.ws .body { padding-top: calc(var(--sat) + 60px); }
|
||||
.cc { left: var(--paneW); }
|
||||
#v-capture { z-index: 45; }
|
||||
.ride { left: var(--paneW); }
|
||||
.concept-tag, .app[data-tab] .concept-tag { top: calc(var(--sat) + 14px); bottom: auto; left: auto; right: 70px; }
|
||||
.composer { left: 10px; right: 10px; }
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
:root { --paneW: 440px; }
|
||||
.masonry { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
/* hinge-aware: vertical fold — panes split exactly at the hinge */
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
:root { --paneW: env(viewport-segment-right 0 0, 50vw); }
|
||||
#gl, .globe-area { left: env(viewport-segment-left 1 0, 50vw); width: auto; right: 0; }
|
||||
#gl { width: calc(100% - env(viewport-segment-left 1 0, 50vw)); }
|
||||
.gtop, .gbottom, .cc, .ride { left: env(viewport-segment-left 1 0, 50vw); }
|
||||
}
|
||||
/* tabletop: globe on the top half, controls on the bottom half */
|
||||
@media (vertical-viewport-segments: 2) {
|
||||
:root { --paneW: 0px; --fold: env(viewport-segment-bottom 0 0, 50vh); --fold2: env(viewport-segment-top 0 1, 50vh); }
|
||||
#gl { height: var(--fold); }
|
||||
.pane { top: var(--fold2); width: 100%; border-right: 0; }
|
||||
.now-hero { display: none; }
|
||||
.gbottom { bottom: auto; top: calc(var(--fold2) + 12px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .12s !important; }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="claude:viewport-fit-sides" content="cover">
|
||||
<title>God's Eye</title>
|
||||
<!--@CSS-->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="gGold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FBE7A9"/><stop offset=".5" stop-color="#E2B654"/><stop offset="1" stop-color="#A97A1F"/></linearGradient>
|
||||
</defs>
|
||||
<symbol id="i-radiowaves" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6M15.8 8.2a5.4 5.4 0 0 1 0 7.6M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/></symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17M12 3c-2.6 2.6-3.6 5.6-3.6 9s1 6.4 3.6 9M12 3c2.6 2.6 3.6 5.6 3.6 9s-1 6.4-3.6 9"/></symbol>
|
||||
<symbol id="i-viewfinder" viewBox="0 0 24 24"><path d="M3.5 8V5.5a2 2 0 0 1 2-2H8M16 3.5h2.5a2 2 0 0 1 2 2V8M20.5 16v2.5a2 2 0 0 1-2 2H16M8 20.5H5.5a2 2 0 0 1-2-2V16"/><circle cx="12" cy="12" r="3.2"/></symbol>
|
||||
<symbol id="i-threads" viewBox="0 0 24 24"><circle cx="5.5" cy="17.5" r="2.3"/><circle cx="18.5" cy="17.5" r="2.3"/><circle cx="12" cy="5.5" r="2.3"/><path d="M7.9 17.5h8.2M6.7 15.4l4.1-7.8M17.3 15.4l-4.1-7.8" stroke-dasharray="1.6 2.2"/></symbol>
|
||||
<symbol id="i-person" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3.2"/><path d="M5.9 18.6c1.3-2.4 3.4-3.6 6.1-3.6s4.8 1.2 6.1 3.6"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.3"/><path d="m15.3 15.3 5 5"/></symbol>
|
||||
<symbol id="i-chevl" viewBox="0 0 24 24"><path d="M14.5 5 7.5 12l7 7"/></symbol>
|
||||
<symbol id="i-chevr" viewBox="0 0 24 24"><path d="m9.5 5 7 7-7 7"/></symbol>
|
||||
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m5 9.5 7 7 7-7"/></symbol>
|
||||
<symbol id="i-chevu" viewBox="0 0 24 24"><path d="m5 14.5 7-7 7 7"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5"/></symbol>
|
||||
<symbol id="i-airplane" viewBox="0 0 24 24"><path d="M12 2.8c.9 0 1.4.9 1.4 2v4.6l7.1 4.2v1.9l-7.1-2.1v4.4l2.2 1.7v1.6L12 20.2l-3.6.9v-1.6l2.2-1.7v-4.4l-7.1 2.1v-1.9l7.1-4.2V4.8c0-1.1.5-2 1.4-2Z"/></symbol>
|
||||
<symbol id="i-jet" viewBox="0 0 24 24"><path d="M12 2.5 13.6 8l6.4 6v1.6l-6.6-2.3-.6 4.2 2.4 2v1.5L12 20l-3.2 1v-1.5l2.4-2-.6-4.2L4 15.6V14l6.4-6Z"/></symbol>
|
||||
<symbol id="i-helicopter" viewBox="0 0 24 24"><path d="M3 5.5h14M10 5.5V8"/><path d="M5.5 10.5h7.2a4.3 4.3 0 0 1 0 8.6H9.4a3.9 3.9 0 0 1-3.9-3.9Z"/><path d="M12.7 12.5H17l3-3.5M8 19.1v1.4M13 19.1v1.4M6 20.5h9"/></symbol>
|
||||
<symbol id="i-propeller" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.8"/><path d="M12 10.2C11 6 11.3 3.5 12 3.5s1 2.5 0 6.7ZM13.6 12.9c3.2 2.9 4.2 5.2 3.6 5.6s-2.6-1.3-4.3-5.2ZM10.4 12.9c-3.2 2.9-4.2 5.2-3.6 5.6s2.6-1.3 4.3-5.2Z"/></symbol>
|
||||
<symbol id="i-exclamation" viewBox="0 0 24 24"><path d="M12 3.5 21.5 20h-19Z"/><path d="M12 9.5v5M12 17.4v.1"/></symbol>
|
||||
<symbol id="i-drone" viewBox="0 0 24 24"><circle cx="5.5" cy="5.5" r="2.5"/><circle cx="18.5" cy="5.5" r="2.5"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/><path d="m7.3 7.3 3 3M16.7 7.3l-3 3M7.3 16.7l3-3M16.7 16.7l-3-3"/><rect x="9.8" y="9.8" width="4.4" height="4.4" rx="1"/></symbol>
|
||||
<symbol id="i-board" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="13" rx="2"/><path d="M6.5 8.5h5M6.5 11.5h7M6.5 14.5h4M16 8.5h1.5M16 11.5h1.5M16 14.5h1.5M9 20.5h6"/></symbol>
|
||||
<symbol id="i-loop" viewBox="0 0 24 24"><rect x="3.5" y="7" width="17" height="10" rx="5"/><path d="m14 4.5 2.5 2.5L14 9.5"/></symbol>
|
||||
<symbol id="i-ferry" viewBox="0 0 24 24"><path d="M4 14.5h16l-2.2 4.2H6.2Z"/><path d="M6.5 14.5V10h11v4.5M9 10V7h6v3M3 21c1.5 0 1.5-.8 3-.8s1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8 1.5-.8 3-.8 1.5.8 3 .8"/></symbol>
|
||||
<symbol id="i-anchor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><path d="M12 7v13.5M8 10.5h8M4.5 13.5c0 3.8 3.4 7 7.5 7s7.5-3.2 7.5-7M4.5 13.5l-1.3 1.6M19.5 13.5l1.3 1.6"/></symbol>
|
||||
<symbol id="i-drop" viewBox="0 0 24 24"><path d="M12 3.2s6 6.6 6 11a6 6 0 0 1-12 0c0-4.4 6-11 6-11Z"/><path d="M9.2 15.2a2.9 2.9 0 0 0 2.6 2.6"/></symbol>
|
||||
<symbol id="i-fish" viewBox="0 0 24 24"><path d="M3 12c3-4.2 7.5-5.7 11.5-4.4 2 .7 3.6 2.2 4.5 4.4-.9 2.2-2.5 3.7-4.5 4.4C10.5 17.7 6 16.2 3 12Z"/><path d="M19 12l2.5-3v6Z"/><circle cx="14.5" cy="11" r=".6" fill="currentColor"/></symbol>
|
||||
<symbol id="i-eyeslash" viewBox="0 0 24 24"><path d="M2.8 12S6.2 5.8 12 5.8 21.2 12 21.2 12s-3.4 6.2-9.2 6.2S2.8 12 2.8 12Z"/><circle cx="12" cy="12" r="2.8"/><path d="M4 20 20 4"/></symbol>
|
||||
<symbol id="i-cable" viewBox="0 0 24 24"><path d="M3 15c3 0 3-6 6-6s3 6 6 6 3-6 6-6"/><circle cx="3" cy="15" r="1.3" fill="currentColor"/><circle cx="21" cy="9" r="1.3" fill="currentColor"/></symbol>
|
||||
<symbol id="i-satellite" viewBox="0 0 24 24"><rect x="9.5" y="9.5" width="5" height="5" rx="1" transform="rotate(45 12 12)"/><path d="m8.4 8.4-3-3M15.6 15.6l3 3"/><rect x="1.8" y="2.8" width="5" height="3" rx=".6" transform="rotate(45 4.3 4.3)"/><rect x="17.2" y="18.2" width="5" height="3" rx=".6" transform="rotate(45 19.7 19.7)"/><path d="M15 6.5a3.5 3.5 0 0 1 2.5 2.5M15.5 3.6a6.4 6.4 0 0 1 4.9 4.9"/></symbol>
|
||||
<symbol id="i-iss" viewBox="0 0 24 24"><path d="M3 12h18M12 9v6"/><rect x="2.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="2.5" y="14" width="4" height="4.5" rx=".6"/><rect x="17.5" y="5.5" width="4" height="4.5" rx=".6"/><rect x="17.5" y="14" width="4" height="4.5" rx=".6"/><rect x="9.5" y="10" width="5" height="4" rx="1"/></symbol>
|
||||
<symbol id="i-train" viewBox="0 0 24 24"><circle cx="4" cy="16" r="1.3"/><circle cx="8" cy="13.6" r="1.3"/><circle cx="12" cy="11.3" r="1.3"/><circle cx="16" cy="9" r="1.3"/><circle cx="20" cy="6.7" r="1.3"/></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 2.8c2.6 2.2 3.8 5.2 3.8 8.6v4.4H8.2v-4.4c0-3.4 1.2-6.4 3.8-8.6Z"/><circle cx="12" cy="9.5" r="1.4"/><path d="M8.2 12.5 5.5 15v3.2l2.7-1.6M15.8 12.5l2.7 2.5v3.2l-2.7-1.6M10.2 18.5c.3 1.3.9 2.2 1.8 2.8.9-.6 1.5-1.5 1.8-2.8"/></symbol>
|
||||
<symbol id="i-debris" viewBox="0 0 24 24"><path d="m4 7 3-2 1 3ZM15 4l4 1-2 3ZM10 12l3-1 1 3-3 1ZM4 17l3 .5-1 2.5ZM17 16l3 1-1 3-3-1Z"/></symbol>
|
||||
<symbol id="i-orbitpath" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="9.5" ry="4.2" transform="rotate(-25 12 12)" stroke-dasharray="2 2.4"/><circle cx="12" cy="12" r="3.2"/><circle cx="19.3" cy="8.4" r="1.5" fill="currentColor"/></symbol>
|
||||
<symbol id="i-wave" viewBox="0 0 24 24"><path d="M2.5 12h3.2l1.6-4 2.4 9.5 2.8-14 2.6 13 1.8-6.5 1.4 2H21.5"/></symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M12 21c-3.9 0-6.5-2.6-6.5-6.2 0-4.3 3.6-6 4.4-10.8 2.6 1.7 4.1 4.2 4.1 6.6 1-.6 1.6-1.6 1.9-2.8 1.7 1.6 2.6 4 2.6 6.6 0 3.9-2.6 6.6-6.5 6.6Z"/><path d="M12 21c-1.7 0-2.8-1.2-2.8-2.8 0-1.9 1.6-2.6 2-4.6 1.6 1 2.6 2.6 2.6 4.4 0 1.8-.6 3-1.8 3Z"/></symbol>
|
||||
<symbol id="i-perimeter" viewBox="0 0 24 24"><path d="M5 9.5 9 4.5l7 1.5 3.5 6-2.5 6.5-7 1L4.5 15Z" stroke-dasharray="2.2 2"/><circle cx="11.5" cy="12" r="2" /></symbol>
|
||||
<symbol id="i-hurricane" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4"/><path d="M12 4.2c-4.8 0-8 3.2-8.3 6.2 2-2.2 4.4-3 6.5-2.6M12 19.8c4.8 0 8-3.2 8.3-6.2-2 2.2-4.4 3-6.5 2.6M9.6 12a7.6 7.6 0 0 0 8-7M14.4 12a7.6 7.6 0 0 0-8 7"/></symbol>
|
||||
<symbol id="i-mountain" viewBox="0 0 24 24"><path d="M2.5 19.5 9.5 7l3.6 6.2 2.4-3.5 6 9.8Z"/><path d="m7.4 10.8 2.1 1.6 1.7-1.3"/></symbol>
|
||||
<symbol id="i-aqi" viewBox="0 0 24 24"><path d="M3 9h11a3 3 0 1 0-3-3M3 14h15a3 3 0 1 1-3 3M3 19h7"/></symbol>
|
||||
<symbol id="i-video" viewBox="0 0 24 24"><path d="M3.5 8.2 15.6 5l1.3 4.8L4.8 13Z"/><path d="m16.2 7.4 3.8-1.2v4.4l-3.1.8M8 12.5l1 4H5.5M3 14.5v4"/></symbol>
|
||||
<symbol id="i-seal" viewBox="0 0 24 24"><path fill="url(#gGold)" stroke="none" d="M12 1.6l2.1 1.6 2.6-.4 1 2.4 2.4 1-.4 2.6 1.6 2.1-1.6 2.1.4 2.6-2.4 1-1 2.4-2.6-.4L12 22.4l-2.1-1.6-2.6.4-1-2.4-2.4-1 .4-2.6L2.7 13l1.6-2.1-.4-2.6 2.4-1 1-2.4 2.6.4Z"/><circle cx="12" cy="12" r="6.2" fill="none" stroke="#7A5512" stroke-width=".8" opacity=".55"/><path d="m8.8 12.2 2.2 2.2 4.2-4.6" fill="none" stroke="#3B2A08" stroke-width="1.8"/></symbol>
|
||||
<symbol id="i-binoculars" viewBox="0 0 24 24"><circle cx="6.5" cy="15.5" r="3.5"/><circle cx="17.5" cy="15.5" r="3.5"/><path d="M10 15h4M3.2 14 5 6.5a2 2 0 0 1 3.9.4V12M20.8 14 19 6.5a2 2 0 0 0-3.9.4V12"/></symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24"><path d="M12 2.8 20 7.2v9.6L12 21.2 4 16.8V7.2Z"/><path d="M4 7.2 12 11.6l8-4.4M12 11.6v9.6"/></symbol>
|
||||
<symbol id="i-live" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4"/><rect x="2.5" y="4.5" width="19" height="15" rx="3"/></symbol>
|
||||
<symbol id="i-tram" viewBox="0 0 24 24"><rect x="5.5" y="5" width="13" height="12.5" rx="3"/><path d="M5.5 11.5h13M9 2.8h6M12 2.8V5M8 17.5 6.5 21M16 17.5l1.5 3.5"/><circle cx="9" cy="14.5" r=".8" fill="currentColor"/><circle cx="15" cy="14.5" r=".8" fill="currentColor"/></symbol>
|
||||
<symbol id="i-car" viewBox="0 0 24 24"><path d="M4 15.5V12l2-4.8a2 2 0 0 1 1.8-1.2h8.4a2 2 0 0 1 1.8 1.2L20 12v3.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1Z"/><path d="M4 12h16M6.5 16.5v2M17.5 16.5v2"/><circle cx="7.5" cy="14" r=".8" fill="currentColor"/><circle cx="16.5" cy="14" r=".8" fill="currentColor"/></symbol>
|
||||
<symbol id="i-bike" viewBox="0 0 24 24"><circle cx="6" cy="15.5" r="3.6"/><circle cx="18" cy="15.5" r="3.6"/><path d="m6 15.5 4-7h6l2 7M10 8.5l2.8 7h-6.8M9 6h2.5M15 5.5h2.5l-1.5 3"/></symbol>
|
||||
<symbol id="i-rail" viewBox="0 0 24 24"><path d="M8 3 5 21M16 3l3 18M7.3 7.5h9.4M6.6 12h10.8M5.8 16.5h12.4"/></symbol>
|
||||
<symbol id="i-ticket" viewBox="0 0 24 24"><path d="M3.5 7.5a1 1 0 0 1 1-1h15a1 1 0 0 1 1 1v2.3a2.2 2.2 0 0 0 0 4.4v2.3a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-2.3a2.2 2.2 0 0 0 0-4.4Z"/><path d="M14.5 6.5v11" stroke-dasharray="1.5 1.8"/></symbol>
|
||||
<symbol id="i-plate" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="2"/><path d="M6 12h2M10 12h4M16 12h2"/></symbol>
|
||||
<symbol id="i-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><path d="M12 12 18.4 5.6"/><circle cx="15.5" cy="9.2" r="1" fill="currentColor"/></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 18.5a4.2 4.2 0 0 1-.6-8.4A5.8 5.8 0 0 1 17.6 9a4.8 4.8 0 0 1-.6 9.5Z"/></symbol>
|
||||
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13.5 2.8 5 13.6h6.2L10.3 21.2l8.7-11h-6.3Z"/></symbol>
|
||||
<symbol id="i-wind" viewBox="0 0 24 24"><path d="M3 9h11.5a2.8 2.8 0 1 0-2.8-2.8M3 13h16a2.8 2.8 0 1 1-2.8 2.8M3 17h7"/></symbol>
|
||||
<symbol id="i-spaghetti" viewBox="0 0 24 24"><path d="M3 18C8 17 12 13 14 6M3 18c6-.5 10-3 15-9M3 18c6 0 11-1.5 17-6"/><circle cx="3" cy="18" r="1.4" fill="currentColor"/></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24"><rect x="4" y="3.5" width="16" height="7" rx="1.6"/><rect x="4" y="13.5" width="16" height="7" rx="1.6"/><path d="M7.5 7h.1M7.5 17h.1M11 7h5.5M11 17h5.5"/></symbol>
|
||||
<symbol id="i-dam" viewBox="0 0 24 24"><path d="M3 5.5h6l2 13H3ZM11 18.5h10M13 12c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9M13 15.5c1.3 0 1.3.9 2.7.9s1.3-.9 2.6-.9 1.3.9 2.7.9"/></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 2.8 19.5 5.6v5.6c0 4.6-3 8.4-7.5 10-4.5-1.6-7.5-5.4-7.5-10V5.6Z"/><path d="M12 7.5v9M8 11.5h8"/></symbol>
|
||||
<symbol id="i-jam" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M8 8a5.6 5.6 0 0 0 0 8M16 8a5.6 5.6 0 0 1 0 8M5.2 5.2a9.6 9.6 0 0 0 0 13.6M18.8 5.2a9.6 9.6 0 0 1 0 13.6M3.5 20.5l17-17"/></symbol>
|
||||
<symbol id="i-frame" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2"/><path d="m3.5 15.5 5-4.5 4 3.5 3-2.5 5 4"/><circle cx="16" cy="9" r="1.5"/></symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></symbol>
|
||||
<symbol id="i-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m12 4.8 2.6 7.2H9.4Z" fill="#FF6A3D" stroke="none"/><path d="m12 19.2-2.6-7.2h5.2Z" fill="currentColor" stroke="none" opacity=".55"/></symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24"><path d="M12 3.5 21 8l-9 4.5L3 8Z"/><path d="m3 12 9 4.5 9-4.5M3 16l9 4.5 9-4.5"/></symbol>
|
||||
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M10 3.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6ZM18 13.5c.3 1.9 1.1 2.7 3 3-1.9.3-2.7 1.1-3 3-.3-1.9-1.1-2.7-3-3 1.9-.3 2.7-1.1 3-3Z"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.6v14.8a.8.8 0 0 0 1.2.7l12-7.4a.8.8 0 0 0 0-1.4l-12-7.4A.8.8 0 0 0 7 4.6Z" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="6" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/><rect x="14" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.6 2H4.4Z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/></symbol>
|
||||
<symbol id="i-bellplus" viewBox="0 0 24 24"><path d="M6 16.5V11a6 6 0 0 1 8-5.7M18 12v4.5l1.6 2H4.4L6 16.5"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0M18.5 3v6M15.5 6h6"/></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24"><path d="M12 14.5V3M8 6.8 12 3l4 3.8M7 10H5.5v10.5h13V10H17"/></symbol>
|
||||
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
|
||||
<symbol id="i-heartf" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M12 20s-7.8-4.6-7.8-10.2A4.4 4.4 0 0 1 12 7a4.4 4.4 0 0 1 7.8 2.8C19.8 15.4 12 20 12 20Z"/></symbol>
|
||||
<symbol id="i-bubble" viewBox="0 0 24 24"><path d="M4 11.5c0-4 3.6-7 8-7s8 3 8 7-3.6 7-8 7c-1 0-1.9-.1-2.8-.4L5 19.8l1.1-3.4C4.8 15.1 4 13.4 4 11.5Z"/></symbol>
|
||||
<symbol id="i-location" viewBox="0 0 24 24"><path d="M20.5 3.5 3.5 10.6l7.3 2.6 2.6 7.3Z"/></symbol>
|
||||
<symbol id="i-home" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.2"/><path d="M8.2 10.5V7.8a3.8 3.8 0 0 1 7.6 0v2.7"/></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
|
||||
<symbol id="i-hash" viewBox="0 0 24 24"><path d="M9.5 3.5 7.5 20.5M16.5 3.5l-2 17M4.5 8.5h16M3.5 15.5h16"/></symbol>
|
||||
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="M3.5 16a8.5 8.5 0 1 1 17 0"/><path d="m12 16 4.5-5.5"/><circle cx="12" cy="16" r="1.3" fill="currentColor"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.3 2"/></symbol>
|
||||
<symbol id="i-wand" viewBox="0 0 24 24"><path d="m4 20 11-11M13.5 7.5l3 3"/><path d="M18 2.8v3M16.5 4.3h3M20.5 8.5v2M19.5 9.5h2M10.5 3v2M9.5 4h2"/></symbol>
|
||||
<symbol id="i-signal" viewBox="0 0 24 24"><path d="M4 19.5v-3M9 19.5v-6M14 19.5v-9M19 19.5v-13"/></symbol>
|
||||
<symbol id="i-arrowup" viewBox="0 0 24 24"><path d="M12 19.5v-15M6 10.5l6-6 6 6"/></symbol>
|
||||
<symbol id="i-thumbup" viewBox="0 0 24 24"><path d="M7.5 10.5v9.5H4.5v-9.5ZM7.5 10.5l3.6-6.6a1.8 1.8 0 0 1 3.3 1.3l-.9 4.3h5.2a1.8 1.8 0 0 1 1.8 2.1l-1.2 6.6a2.2 2.2 0 0 1-2.2 1.8H7.5"/></symbol>
|
||||
<symbol id="i-thumbdown" viewBox="0 0 24 24"><path d="M7.5 13.5V4H4.5v9.5ZM7.5 13.5l3.6 6.6a1.8 1.8 0 0 0 3.3-1.3l-.9-4.3h5.2a1.8 1.8 0 0 0 1.8-2.1L19.3 5.8A2.2 2.2 0 0 0 17.1 4H7.5"/></symbol>
|
||||
<symbol id="i-gyro" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><ellipse cx="12" cy="12" rx="8.5" ry="3.2"/><ellipse cx="12" cy="12" rx="3.2" ry="8.5"/></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/><path d="M9.5 3v3.5M14.5 3v3.5M9.5 17.5V21M14.5 17.5V21M3 9.5h3.5M3 14.5h3.5M17.5 9.5H21M17.5 14.5H21"/></symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="14.5" r="4"/><path d="m10.4 11.6 9.1-9.1M16.5 5.5l2.5 2.5M14 8l2 2"/></symbol>
|
||||
<symbol id="i-flip" viewBox="0 0 24 24"><path d="M4 8.5h13.5l-3-3M20 15.5H6.5l3 3"/></symbol>
|
||||
<symbol id="i-gallery" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="m3.5 15 4.5-4 4 3.5 3-2.5 5.5 4.5"/></symbol>
|
||||
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4M5 4.5h12l-2.5 4 2.5 4H5"/></symbol>
|
||||
<symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19c.7-3 2.8-4.6 5.5-4.6s4.8 1.6 5.5 4.6"/><circle cx="16.5" cy="9" r="2.4"/><path d="M15.2 14.6c2.6-.3 4.6 1 5.3 3.9"/></symbol>
|
||||
</svg>
|
||||
|
||||
<div id="app" class="app" data-tab="now" data-mode="">
|
||||
<canvas id="gl" aria-label="Interactive 3D globe"></canvas>
|
||||
<div class="globe-area"><div id="labels" class="labels"></div></div>
|
||||
|
||||
<div class="pane">
|
||||
<section id="v-now" class="view on" aria-label="Now"></section>
|
||||
<section id="v-threads" class="view" aria-label="Threads"></section>
|
||||
<section id="v-you" class="view" aria-label="You"></section>
|
||||
<section id="v-layers" class="view wide-only-view" aria-label="Layers"></section>
|
||||
</div>
|
||||
|
||||
<!-- globe chrome -->
|
||||
<div class="gtop gchrome flexy">
|
||||
<button class="status glass" id="status"><span class="dot pulse"></span><span id="statusTxt">Live</span><span class="t num" id="statusTime"></span></button>
|
||||
<div class="gbtns">
|
||||
<button class="gbtn glass compass" id="btnCompass" aria-label="Reset north"><svg class="ic"><use href="#i-compass"/></svg></button>
|
||||
<button class="gbtn glass" id="btnLayers" aria-label="Layers"><svg class="ic"><use href="#i-layers"/></svg></button>
|
||||
<button class="gbtn glass" id="btnLens" aria-label="Lens"><svg class="ic"><use href="#i-sparkles"/></svg></button>
|
||||
<button class="gbtn glass" id="btnHome" aria-label="Planet view"><svg class="ic"><use href="#i-globe"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gbottom gchrome flexy" id="gbottom">
|
||||
<div class="pick glass strong" id="pick"></div>
|
||||
<div class="dock" id="dock"></div>
|
||||
<div class="ribbon glass" id="ribbon"><canvas></canvas><div class="head"></div><div class="readout"><span id="rbTime" class="num"></span><span id="rbRel" class="rel num"></span></div><span class="fc">FORECAST</span><button class="nowbtn live" id="rbNow"><span class="dot"></span>NOW</button></div>
|
||||
</div>
|
||||
<button class="backbtn circ glass" id="back" aria-label="Back"><svg class="ic"><use href="#i-chevl"/></svg></button>
|
||||
<div class="sitbtns" id="sitbtns"><button class="circ glass" id="sitFollow" aria-label="Follow"><svg class="ic"><use href="#i-bellplus"/></svg></button><button class="circ glass" id="sitShare" aria-label="Share"><svg class="ic"><use href="#i-share"/></svg></button></div>
|
||||
<div class="sheet glass strong" id="sheet"><div class="grab" id="grab"></div><div class="sc" id="sheetC"></div></div>
|
||||
|
||||
<div class="ride" id="ride"></div>
|
||||
<section id="v-capture" class="view" aria-label="Capture"></section>
|
||||
<div class="cc" id="cc"></div>
|
||||
<div class="search" id="search"></div>
|
||||
|
||||
<div class="acc-host" id="accHost"><button class="accessory glass" id="acc"></button></div>
|
||||
<nav class="tabwrap" id="tabwrap">
|
||||
<div class="tabbar glass" id="tabbar" role="tablist">
|
||||
<div class="tablens" id="tablens"></div>
|
||||
<button class="tab on" data-tab="now" role="tab"><svg class="ic"><use href="#i-radiowaves"/></svg><span>Now</span></button>
|
||||
<button class="tab" data-tab="globe" role="tab"><svg class="ic"><use href="#i-globe"/></svg><span>Globe</span></button>
|
||||
<button class="tab" data-tab="capture" role="tab"><svg class="ic"><use href="#i-viewfinder"/></svg><span>Capture</span></button>
|
||||
<button class="tab" data-tab="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Threads</span></button>
|
||||
<button class="tab" data-tab="you" role="tab"><svg class="ic"><use href="#i-person"/></svg><span>You</span></button>
|
||||
</div>
|
||||
<button class="accessory glass" id="accMini"></button>
|
||||
<button class="searchbtn glass" id="btnSearch" aria-label="Search"><svg class="ic"><use href="#i-search"/></svg></button>
|
||||
</nav>
|
||||
|
||||
<div class="morph" id="morph"></div>
|
||||
<div class="seal-ov" id="sealOv"></div>
|
||||
<div class="inspector" id="inspector"></div>
|
||||
<div class="toastp glass" id="toast"></div>
|
||||
<div class="concept-tag">Concept · illustrative data</div>
|
||||
<div class="cold" id="cold">
|
||||
<button class="skip glass" id="skip">Skip</button>
|
||||
<svg class="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/></svg>
|
||||
<div class="word">God’s Eye</div>
|
||||
<div class="tag">The window to the world.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--@TEX-->
|
||||
<!--@JS-->
|
||||
@@ -0,0 +1,240 @@
|
||||
/* God's Eye — vision mockup. Utilities: math, geo, sun, rng, DOM, formatting. */
|
||||
const GE = (window.GE = window.GE || {});
|
||||
const D2R = Math.PI / 180, R2D = 180 / Math.PI, TAU = Math.PI * 2;
|
||||
const EARTH_KM = 6371.0, EARTH_M = 6371000.0;
|
||||
const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
|
||||
const ease = {
|
||||
inOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
|
||||
out: (t) => 1 - Math.pow(1 - t, 3),
|
||||
outQuint: (t) => 1 - Math.pow(1 - t, 5),
|
||||
inOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
||||
outBack: (t) => { const c1 = 1.4, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); },
|
||||
};
|
||||
const prefersReduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
|
||||
|
||||
/* ---------- vec3 / mat4 (Float64 arrays for CPU precision) ---------- */
|
||||
const v3 = {
|
||||
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
|
||||
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
|
||||
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
|
||||
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
|
||||
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
|
||||
len: (a) => Math.hypot(a[0], a[1], a[2]),
|
||||
norm: (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; },
|
||||
lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t],
|
||||
madd: (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s],
|
||||
};
|
||||
const m4 = {
|
||||
ident: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
mul(a, b) { // column-major a*b
|
||||
const o = new Array(16);
|
||||
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++)
|
||||
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
||||
return o;
|
||||
},
|
||||
persp(fovy, aspect, n, f) {
|
||||
const t = 1 / Math.tan(fovy / 2);
|
||||
return [t / aspect, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, (2 * f * n) / (n - f), 0];
|
||||
},
|
||||
// rotation-only view from basis (right, up, back) rows
|
||||
viewRot(right, up, back) {
|
||||
return [right[0], up[0], back[0], 0, right[1], up[1], back[1], 0, right[2], up[2], back[2], 0, 0, 0, 0, 1];
|
||||
},
|
||||
invert(m) {
|
||||
const a = m, o = new Array(16);
|
||||
const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3], a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
|
||||
const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11], a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
|
||||
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11;
|
||||
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30;
|
||||
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32;
|
||||
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
|
||||
if (!det) return m4.ident();
|
||||
det = 1 / det;
|
||||
o[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; o[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
|
||||
o[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; o[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
|
||||
o[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; o[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
|
||||
o[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; o[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
|
||||
o[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; o[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
|
||||
o[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; o[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
|
||||
o[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; o[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
|
||||
o[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; o[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
|
||||
return o;
|
||||
},
|
||||
xform(m, p) { // returns clip vec4
|
||||
const x = p[0], y = p[1], z = p[2];
|
||||
return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14], m[3] * x + m[7] * y + m[11] * z + m[15]];
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- geo (unit sphere, x→(0,0), y→(0,90E), z→north) ---------- */
|
||||
const geo = {
|
||||
vec(lat, lon, altKm = 0) {
|
||||
const r = 1 + altKm / EARTH_KM, la = lat * D2R, lo = lon * D2R, c = Math.cos(la);
|
||||
return [r * c * Math.cos(lo), r * c * Math.sin(lo), r * Math.sin(la)];
|
||||
},
|
||||
ll(v) { const r = v3.len(v); return [Math.asin(clamp(v[2] / r, -1, 1)) * R2D, Math.atan2(v[1], v[0]) * R2D, (r - 1) * EARTH_KM]; },
|
||||
enu(lat, lon) { // east, north, up
|
||||
const la = lat * D2R, lo = lon * D2R;
|
||||
const up = [Math.cos(la) * Math.cos(lo), Math.cos(la) * Math.sin(lo), Math.sin(la)];
|
||||
const east = [-Math.sin(lo), Math.cos(lo), 0];
|
||||
const north = [-Math.sin(la) * Math.cos(lo), -Math.sin(la) * Math.sin(lo), Math.cos(la)];
|
||||
return { east, north, up };
|
||||
},
|
||||
dist(lat1, lon1, lat2, lon2) { // radians
|
||||
const p1 = lat1 * D2R, p2 = lat2 * D2R, dp = p2 - p1, dl = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
},
|
||||
bearing(lat1, lon1, lat2, lon2) {
|
||||
const p1 = lat1 * D2R, p2 = lat2 * D2R, dl = (lon2 - lon1) * D2R;
|
||||
return Math.atan2(Math.sin(dl) * Math.cos(p2), Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl));
|
||||
},
|
||||
slerp(a, b, t) {
|
||||
const d = clamp(v3.dot(a, b), -1, 1), om = Math.acos(d);
|
||||
if (om < 1e-9) return a.slice();
|
||||
const s = Math.sin(om);
|
||||
return v3.add(v3.scale(a, Math.sin((1 - t) * om) / s), v3.scale(b, Math.sin(t * om) / s));
|
||||
},
|
||||
// destination from lat/lon, bearing (rad), distance (km)
|
||||
dest(lat, lon, brg, km) {
|
||||
const d = km / EARTH_KM, p1 = lat * D2R, l1 = lon * D2R;
|
||||
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(brg));
|
||||
const l2 = l1 + Math.atan2(Math.sin(brg) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
|
||||
return [p2 * R2D, ((l2 * R2D + 540) % 360) - 180];
|
||||
},
|
||||
// local ENU meters offset -> lat/lon
|
||||
offset(lat, lon, eM, nM) { return [lat + (nM / EARTH_M) * R2D, lon + (eM / (EARTH_M * Math.cos(lat * D2R))) * R2D]; },
|
||||
};
|
||||
|
||||
/* ---------- sun & sidereal time ---------- */
|
||||
const astro = {
|
||||
jd: (ms) => ms / 86400000 + 2440587.5,
|
||||
// returns { lat, lon, dir:[x,y,z], gmst(rad) }
|
||||
sun(ms) {
|
||||
const d = astro.jd(ms) - 2451545.0;
|
||||
const g = (357.529 + 0.98560028 * d) * D2R;
|
||||
const q = 280.459 + 0.98564736 * d;
|
||||
const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R;
|
||||
const e = (23.439 - 0.00000036 * d) * D2R;
|
||||
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
|
||||
const dec = Math.asin(Math.sin(e) * Math.sin(L));
|
||||
const gmst = ((18.697374558 + 24.06570982441908 * d) % 24) * 15 * D2R;
|
||||
let lon = (ra - gmst) * R2D;
|
||||
lon = ((lon % 360) + 540) % 360 - 180;
|
||||
const lat = dec * R2D;
|
||||
return { lat, lon, dir: geo.vec(lat, lon), gmst: ((gmst % TAU) + TAU) % TAU };
|
||||
},
|
||||
// sun elevation (deg) at a place
|
||||
elev(ms, lat, lon) { const s = astro.sun(ms); return 90 - geo.dist(lat, lon, s.lat, s.lon) * R2D; },
|
||||
};
|
||||
|
||||
/* ---------- seeded rng ---------- */
|
||||
function rng(seed) {
|
||||
let a = seed >>> 0;
|
||||
const f = () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
f.range = (a0, b0) => a0 + (b0 - a0) * f();
|
||||
f.int = (n) => Math.floor(f() * n);
|
||||
f.pick = (arr) => arr[Math.floor(f() * arr.length)];
|
||||
f.gauss = () => { let u = 0, v = 0; while (!u) u = f(); while (!v) v = f(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v); };
|
||||
f.wpick = (arr, w) => { let s = 0; for (const x of arr) s += w(x); let r = f() * s; for (const x of arr) { r -= w(x); if (r <= 0) return x; } return arr[arr.length - 1]; };
|
||||
return f;
|
||||
}
|
||||
const hash01 = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
||||
|
||||
/* ---------- DOM ---------- */
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||||
function h(tag, attrs, ...kids) {
|
||||
const e = document.createElement(tag);
|
||||
if (attrs) for (const k in attrs) {
|
||||
const v = attrs[k];
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
|
||||
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
||||
else e.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const k of kids.flat()) if (k != null && k !== false) e.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
return e;
|
||||
}
|
||||
const ic = (name, cls = '') => `<svg class="ic ${cls}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
/* ---------- formatting ---------- */
|
||||
const fmt = {
|
||||
n(x, d = 0) { return Number(x).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); },
|
||||
k(x) { return x >= 1e6 ? (x / 1e6).toFixed(1) + 'M' : x >= 1e4 ? Math.round(x / 1e3) + 'k' : x >= 1e3 ? (x / 1e3).toFixed(1) + 'k' : String(Math.round(x)); },
|
||||
ago(ms) {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 5) return 'now';
|
||||
if (s < 60) return s + ' s ago';
|
||||
if (s < 3600) return Math.round(s / 60) + ' min ago';
|
||||
if (s < 86400) return Math.round(s / 3600) + ' h ago';
|
||||
return Math.round(s / 86400) + ' d ago';
|
||||
},
|
||||
utc(ms, withDay = true) {
|
||||
const d = new Date(ms);
|
||||
const dd = d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
const tt = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
|
||||
return withDay ? `${dd} · ${tt} UTC` : tt;
|
||||
},
|
||||
hm(ms) { const d = new Date(ms); return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0'); },
|
||||
rel(h) { // hours offset → "−26 h" / "+4 h 30 m"
|
||||
const sign = h < 0 ? '−' : '+', a = Math.abs(h), H = Math.floor(a), M = Math.round((a - H) * 60);
|
||||
if (a < 1 / 60) return 'Live';
|
||||
return `${sign}${H ? H + ' h' : ''}${M && H < 24 ? (H ? ' ' : '') + M + ' m' : ''}`;
|
||||
},
|
||||
ll(lat, lon, d = 3) { return `${Math.abs(lat).toFixed(d)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(d)}°${lon >= 0 ? 'E' : 'W'}`; },
|
||||
dur(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}`; },
|
||||
hex: (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
|
||||
/* ---------- tiny event bus ---------- */
|
||||
const bus = {
|
||||
m: new Map(),
|
||||
on(e, f) { if (!this.m.has(e)) this.m.set(e, new Set()); this.m.get(e).add(f); return () => this.m.get(e).delete(f); },
|
||||
off(e, f) { this.m.get(e)?.delete(f); },
|
||||
emit(e, d) { this.m.get(e)?.forEach((f) => { try { f(d); } catch (err) { console.warn(err); } }); },
|
||||
};
|
||||
|
||||
/* ---------- storage (conveniences only) ---------- */
|
||||
const store = {
|
||||
get(k, d) { try { const v = localStorage.getItem('ge.v1.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
||||
set(k, v) { try { localStorage.setItem('ge.v1.' + k, JSON.stringify(v)); } catch {} },
|
||||
};
|
||||
const haptic = (p = 8) => { try { navigator.vibrate && navigator.vibrate(p); } catch {} };
|
||||
|
||||
/* ---------- hashing ---------- */
|
||||
// SHA-256 fallback (only used if crypto.subtle is unavailable)
|
||||
function sha256js(bytes) {
|
||||
const K = new Uint32Array([0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]);
|
||||
const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
|
||||
const l = bytes.length, nb = ((l + 9 + 63) >> 6) << 6, m = new Uint8Array(nb);
|
||||
m.set(bytes); m[l] = 0x80; const dv = new DataView(m.buffer); dv.setUint32(nb - 4, l * 8); dv.setUint32(nb - 8, Math.floor((l * 8) / 4294967296));
|
||||
const W = new Uint32Array(64); const r = (x, n) => (x >>> n) | (x << (32 - n));
|
||||
for (let o = 0; o < nb; o += 64) {
|
||||
for (let i = 0; i < 16; i++) W[i] = dv.getUint32(o + i * 4);
|
||||
for (let i = 16; i < 64; i++) { const s0 = r(W[i-15],7)^r(W[i-15],18)^(W[i-15]>>>3), s1 = r(W[i-2],17)^r(W[i-2],19)^(W[i-2]>>>10); W[i] = (W[i-16]+s0+W[i-7]+s1)|0; }
|
||||
let [a,b,c,d,e,f,g,hh] = H;
|
||||
for (let i = 0; i < 64; i++) { const S1=r(e,6)^r(e,11)^r(e,25), ch=(e&f)^(~e&g), t1=(hh+S1+ch+K[i]+W[i])|0, S0=r(a,2)^r(a,13)^r(a,22), mj=(a&b)^(a&c)^(b&c), t2=(S0+mj)|0; hh=g; g=f; f=e; e=(d+t1)|0; d=c; c=b; b=a; a=(t1+t2)|0; }
|
||||
H[0]+=a; H[1]+=b; H[2]+=c; H[3]+=d; H[4]+=e; H[5]+=f; H[6]+=g; H[7]+=hh;
|
||||
}
|
||||
const out = new Uint8Array(32); const ov = new DataView(out.buffer); for (let i = 0; i < 8; i++) ov.setUint32(i * 4, H[i]); return out.buffer;
|
||||
}
|
||||
const hasSubtle = () => { try { return !!(window.crypto && crypto.subtle && crypto.subtle.digest); } catch { return false; } };
|
||||
async function sha256(data) {
|
||||
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
if (hasSubtle()) { try { return fmt.hex(await crypto.subtle.digest('SHA-256', bytes)); } catch {} }
|
||||
return fmt.hex(sha256js(bytes));
|
||||
}
|
||||
// canonical JSON (sorted keys) for hashing
|
||||
function canon(o) {
|
||||
if (o === null || typeof o !== 'object') return JSON.stringify(o);
|
||||
if (Array.isArray(o)) return '[' + o.map(canon).join(',') + ']';
|
||||
return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + canon(o[k])).join(',') + '}';
|
||||
}
|
||||
|
||||
Object.assign(GE, { D2R, R2D, TAU, EARTH_KM, clamp, lerp, smooth, ease, v3, m4, geo, astro, rng, fmt, bus, store, sha256, canon });
|
||||
@@ -0,0 +1,281 @@
|
||||
/* Synthetic, plausible world data. Real places & public phenomena; all people, handles, posts,
|
||||
callsigns and reconstruction claims are illustrative (Concept · illustrative data). */
|
||||
const NOW = Date.now();
|
||||
GE.NOW = NOW;
|
||||
const R = rng(20261018);
|
||||
|
||||
/* ---------- airports [iata, city, lat, lon, weight] ---------- */
|
||||
const AIRPORTS = [
|
||||
['ATL','Atlanta',33.6407,-84.4277,104],['DXB','Dubai',25.2532,55.3657,87],['DFW','Dallas–Fort Worth',32.8998,-97.0403,82],['LHR','London Heathrow',51.47,-0.4543,80],
|
||||
['HND','Tokyo Haneda',35.5494,139.7798,79],['DEN','Denver',39.8561,-104.6737,78],['IST','Istanbul',41.2753,28.7519,76],['LAX','Los Angeles',33.9416,-118.4085,75],
|
||||
['ORD','Chicago O’Hare',41.9742,-87.9073,74],['DEL','Delhi',28.5562,77.1,73],['CDG','Paris CDG',49.0097,2.5479,70],['JFK','New York JFK',40.6413,-73.7781,62],
|
||||
['CAN','Guangzhou',23.3924,113.2988,64],['PEK','Beijing',40.0799,116.6031,60],['PVG','Shanghai Pudong',31.1443,121.8083,62],['SIN','Singapore',1.3644,103.9915,59],
|
||||
['AMS','Amsterdam',52.3105,4.7683,62],['FRA','Frankfurt',50.0379,8.5622,60],['MAD','Madrid',40.4983,-3.5676,60],['BKK','Bangkok',13.69,100.7501,58],
|
||||
['ICN','Seoul Incheon',37.4602,126.4407,56],['BOM','Mumbai',19.0896,72.8656,52],['CGK','Jakarta',-6.1256,106.6559,50],['SFO','San Francisco',37.6213,-122.379,50],
|
||||
['LAS','Las Vegas',36.084,-115.1537,55],['MCO','Orlando',28.4312,-81.3081,57],['MIA','Miami',25.7959,-80.287,52],['CLT','Charlotte',35.214,-80.9431,53],
|
||||
['SEA','Seattle',47.4502,-122.3088,50],['PHX','Phoenix',33.4342,-112.0116,48],['EWR','Newark',40.6895,-74.1745,49],['IAH','Houston',29.9902,-95.3368,46],
|
||||
['BOS','Boston',42.3656,-71.0096,40],['MSP','Minneapolis',44.8848,-93.2223,36],['DTW','Detroit',42.2162,-83.3554,32],['YYZ','Toronto',43.6777,-79.6248,45],
|
||||
['YVR','Vancouver',49.1967,-123.1815,25],['MEX','Mexico City',19.4361,-99.0719,48],['CUN','Cancún',21.0365,-86.877,30],['GRU','São Paulo',-23.4356,-46.4731,42],
|
||||
['BOG','Bogotá',4.7016,-74.1469,38],['LIM','Lima',-12.0219,-77.1143,24],['SCL','Santiago',-33.393,-70.7858,24],['EZE','Buenos Aires',-34.8222,-58.5358,12],
|
||||
['GIG','Rio de Janeiro',-22.809,-43.2506,15],['PTY','Panama City',9.0714,-79.3835,17],['MUC','Munich',48.3537,11.775,41],['FCO','Rome',41.8003,12.2389,40],
|
||||
['BCN','Barcelona',41.2974,2.0833,52],['LGW','London Gatwick',51.1537,-0.1821,41],['ZRH','Zurich',47.4582,8.5555,31],['VIE','Vienna',48.1103,16.5697,29],
|
||||
['CPH','Copenhagen',55.618,12.6508,30],['OSL','Oslo',60.1976,11.1004,27],['ARN','Stockholm',59.6498,17.9238,25],['HEL','Helsinki',60.3172,24.9633,18],
|
||||
['DUB','Dublin',53.4264,-6.2499,33],['LIS','Lisbon',38.7742,-9.1342,33],['ATH','Athens',37.9364,23.9445,28],['WAW','Warsaw',52.1657,20.9671,21],
|
||||
['PRG','Prague',50.1008,14.26,17],['BRU','Brussels',50.9014,4.4844,24],['MAN','Manchester',53.3537,-2.275,29],['PMI','Palma',39.5517,2.7388,33],
|
||||
['DOH','Doha',25.2731,51.6081,45],['AUH','Abu Dhabi',24.433,54.6511,28],['RUH','Riyadh',24.9576,46.6988,36],['JED','Jeddah',21.6702,39.1525,42],
|
||||
['CAI','Cairo',30.1219,31.4056,30],['TLV','Tel Aviv',32.0055,34.8854,24],['ADD','Addis Ababa',8.9779,38.7993,13],['NBO','Nairobi',-1.3192,36.9278,9],
|
||||
['JNB','Johannesburg',-26.1392,28.246,21],['CPT','Cape Town',-33.9715,18.6021,11],['LOS','Lagos',6.5774,3.3212,8],['CMN','Casablanca',33.3675,-7.5898,10],
|
||||
['HKG','Hong Kong',22.308,113.9185,53],['TPE','Taipei',25.0797,121.2342,45],['NRT','Tokyo Narita',35.772,140.3929,33],['KIX','Osaka Kansai',34.4347,135.244,28],
|
||||
['MNL','Manila',14.5086,121.0198,45],['KUL','Kuala Lumpur',2.7456,101.7072,57],['SGN','Ho Chi Minh City',10.8188,106.6519,40],['HAN','Hanoi',21.2187,105.8042,29],
|
||||
['CTU','Chengdu',30.5785,103.9471,50],['SZX','Shenzhen',22.6393,113.8107,52],['KMG','Kunming',25.1019,102.9292,45],['XIY','Xi’an',34.4471,108.7516,43],
|
||||
['CKG','Chongqing',29.7192,106.6417,44],['HGH','Hangzhou',30.2295,120.4344,40],['BLR','Bengaluru',13.1986,77.7066,37],['MAA','Chennai',12.9941,80.1709,22],
|
||||
['CCU','Kolkata',22.6547,88.4467,22],['HYD','Hyderabad',17.2403,78.4294,21],['KTM','Kathmandu',27.6966,85.3591,8],['DAC','Dhaka',23.8433,90.3978,9],
|
||||
['KHI','Karachi',24.9065,67.1608,8],['SYD','Sydney',-33.9399,151.1753,44],['MEL','Melbourne',-37.669,144.841,37],['BNE','Brisbane',-27.3842,153.1175,24],
|
||||
['PER','Perth',-31.9385,115.9672,15],['AKL','Auckland',-37.0082,174.785,21],['HNL','Honolulu',21.3187,-157.9225,21],['ANC','Anchorage',61.1743,-149.9963,6],
|
||||
['SVO','Moscow',55.9726,37.4146,40],['LED','St Petersburg',59.8003,30.2625,19],['ALA','Almaty',43.3521,77.0405,9],['TAS','Tashkent',41.2579,69.2812,7],
|
||||
['RKV','Reykjavík',63.985,-22.6056,8],['SLC','Salt Lake City',40.7899,-111.9791,26],['SAN','San Diego',32.7338,-117.1933,25],['PDX','Portland',45.5898,-122.5951,19],
|
||||
['AUS','Austin',30.1975,-97.6664,21],['BNA','Nashville',36.1263,-86.6774,22],['MSY','New Orleans',29.9934,-90.258,13],['YUL','Montréal',45.4706,-73.7408,21],
|
||||
['YYC','Calgary',51.1215,-114.0076,18],['GUA','Guatemala City',14.5833,-90.5275,3],['SJO','San José CR',9.9939,-84.2088,5],['UIO','Quito',-0.1292,-78.3575,5],
|
||||
];
|
||||
const AP = AIRPORTS.map(([iata, city, lat, lon, w]) => ({ iata, city, lat, lon, w, v: geo.vec(lat, lon) }));
|
||||
const apByIata = Object.fromEntries(AP.map((a) => [a.iata, a]));
|
||||
|
||||
/* ---------- fictional operators ---------- */
|
||||
const OPS = ['AXR','NVA','SKL','ORX','VLT','KTO','LMA','TRV','QNX','PLR','HZN','CRS','SOL','MRD','EQX','BLU','CYG','ALD','JTR','FRN'];
|
||||
const OPNAME = { AXR:'Axiom Air', NVA:'Nova Atlantic', SKL:'Skylark', ORX:'Orix Cargo', VLT:'Volta', KTO:'Kito Air', LMA:'Lumea', TRV:'Traverse', QNX:'Quanta', PLR:'Polaris', HZN:'Horizonte', CRS:'Cirrus Lines', SOL:'Solstice', MRD:'Meridian', EQX:'Equinox', BLU:'Bluewing', CYG:'Cygnet', ALD:'Aldea', JTR:'Jetrail', FRN:'Fernway' };
|
||||
const TYPES_SHORT = ['A320','A21N','B738','B38M','E190','A20N','AT76','CRJ9'];
|
||||
const TYPES_LONG = ['B77W','A359','B789','A388','B78X','A35K','B744F','A333'];
|
||||
|
||||
/* ---------- flights (GPU-animated great circles) ----------
|
||||
Each flight cycles: fly a→b for dur, turn around, fly b→a… so any scrub time is consistent. */
|
||||
const FLIGHTS = [];
|
||||
(function genFlights() {
|
||||
const N = 3600;
|
||||
const wsum = AP.reduce((s, a) => s + a.w, 0);
|
||||
const pickAp = () => { let r = R() * wsum; for (const a of AP) { r -= a.w; if (r <= 0) return a; } return AP[0]; };
|
||||
let guard = 0;
|
||||
while (FLIGHTS.length < N && guard++ < N * 8) {
|
||||
const a = pickAp(); let b = pickAp();
|
||||
if (a === b) continue;
|
||||
const d = geo.dist(a.lat, a.lon, b.lat, b.lon) * EARTH_KM;
|
||||
if (d < 250 || d > 15500) continue;
|
||||
// short-haul bias
|
||||
if (d > 6000 && R() < 0.55) continue;
|
||||
const long = d > 4500;
|
||||
const speed = long ? 905 : 780; // km/h
|
||||
const dur = (d / speed + 0.45) * 3600; // seconds
|
||||
const turn = (long ? R.range(1.5, 3.5) : R.range(0.6, 2.2)) * 3600;
|
||||
const period = 2 * (dur + turn);
|
||||
const op = R.pick(OPS);
|
||||
FLIGHTS.push({
|
||||
kind: 0, a, b, dKm: d, dur, turn, period, t0: R() * period, alt: long ? R.range(10.6, 12.5) : R.range(8.5, 11.6),
|
||||
cs: op + (R.int(8900) + 100), op, type: long ? R.pick(TYPES_LONG) : R.pick(TYPES_SHORT), speed,
|
||||
});
|
||||
}
|
||||
// private / GA — short hops near busy regions
|
||||
for (let i = 0; i < 380; i++) {
|
||||
const a = R.pick(AP); const brg = R() * TAU, km = R.range(80, 520);
|
||||
const [lat, lon] = geo.dest(a.lat, a.lon, brg, km);
|
||||
const b = { iata: 'GA', city: '', lat, lon, w: 1, v: geo.vec(lat, lon) };
|
||||
const dur = (km / 330 + 0.2) * 3600, turn = R.range(0.5, 4) * 3600;
|
||||
FLIGHTS.push({ kind: 3, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(1.5, 4.5), cs: 'N' + (R.int(899) + 100) + String.fromCharCode(65 + R.int(26), 65 + R.int(26)), op: 'GA', type: R.pick(['C172','PC12','SR22','C25A','BE20']), speed: 330 });
|
||||
}
|
||||
// military transits (fictional callsigns)
|
||||
const MIL = [[55.0,10.0],[50.0,20.0],[44.0,30.0],[37.0,24.0],[36.5,-76.0],[32.0,-110.0],[26.0,51.0],[35.0,128.0],[21.0,-158.0],[13.5,144.8],[51.0,-1.5],[64.0,25.0],[57.0,22.0],[54.5,18.0]];
|
||||
const MCS = ['VIPER','TITAN','HAWK','RAVEN','SABRE','ATLAS','GHOST','DUKE','NOMAD','LANCE'];
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const c = R.pick(MIL); const [la1, lo1] = geo.dest(c[0], c[1], R() * TAU, R.range(50, 600)); const [la2, lo2] = geo.dest(c[0], c[1], R() * TAU, R.range(300, 1600));
|
||||
const a = { iata: 'MIL', lat: la1, lon: lo1, v: geo.vec(la1, lo1) }, b = { iata: 'MIL', lat: la2, lon: lo2, v: geo.vec(la2, lo2) };
|
||||
const km = geo.dist(la1, lo1, la2, lo2) * EARTH_KM; const dur = (km / 760 + 0.2) * 3600, turn = R.range(0.3, 3) * 3600;
|
||||
FLIGHTS.push({ kind: 1, a, b, dKm: km, dur, turn, period: 2 * (dur + turn), t0: R() * 2 * (dur + turn), alt: R.range(6, 11), cs: R.pick(MCS) + ' ' + (R.int(89) + 10), op: 'MIL', type: R.pick(['KC-135','C-17','P-8','E-3','RC-135','A400','F-35','MQ-9']), speed: 760 });
|
||||
}
|
||||
})();
|
||||
// position of a flight at sim seconds t → {v:[x,y,z] (unit, alt applied), lat, lon, altKm, hdg, prog, onLeg}
|
||||
function flightAt(f, t) {
|
||||
let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period;
|
||||
const leg = ph < f.period / 2 ? 0 : 1; const lp = ph - leg * (f.period / 2);
|
||||
if (lp > f.dur) return null;
|
||||
const p = lp / f.dur; const A = leg ? f.b : f.a, B = leg ? f.a : f.b;
|
||||
const s = geo.slerp(A.v, B.v, p);
|
||||
const climb = Math.min(1, Math.min(p, 1 - p) * f.dKm / 160);
|
||||
const altKm = f.alt * Math.sqrt(Math.max(0, climb));
|
||||
const [lat, lon] = geo.ll(s);
|
||||
const s2 = geo.slerp(A.v, B.v, Math.min(1, p + 0.001)); const [lat2, lon2] = geo.ll(s2);
|
||||
return { v: s, lat, lon, altKm, hdg: geo.bearing(lat, lon, lat2, lon2), prog: p, from: A, to: B };
|
||||
}
|
||||
|
||||
/* ---------- shipping lanes (lat,lon chains on water) ---------- */
|
||||
const LANES = [
|
||||
// Asia–Europe via Suez
|
||||
[[31.0,122.6],[28.0,122.6],[24.6,119.9],[22.0,116.0],[18.0,113.5],[12.0,110.4],[7.0,106.0],[2.6,104.9],[1.25,104.0],[1.9,102.4],[3.4,100.6],[5.9,97.2],[6.1,94.0],[6.0,88.0],[5.7,81.0],[7.0,76.5],[11.0,64.0],[12.2,52.0],[12.0,46.5],[12.6,43.4],[14.5,42.4],[18.0,40.3],[22.0,37.9],[25.5,35.3],[27.8,33.7],[29.6,32.6],[30.6,32.33],[31.4,32.33],[32.6,30.0],[34.0,24.5],[36.1,16.0],[37.3,11.6],[37.6,8.0],[36.6,2.0],[35.95,-5.6],[36.6,-9.6],[40.0,-10.2],[43.6,-9.9],[48.4,-5.8],[49.9,-2.3],[50.9,1.3],[51.6,2.8],[52.0,3.95]],
|
||||
// Transpacific (Shanghai → LA)
|
||||
[[31.0,122.6],[30.3,128.5],[33.0,135.5],[34.6,139.9],[36.5,152.0],[38.0,165.0],[38.5,180.0],[37.5,-165.0],[35.5,-145.0],[34.0,-128.0],[33.7,-118.4]],
|
||||
// Busan → Seattle (great circle north)
|
||||
[[35.0,129.1],[36.5,135.0],[41.4,141.6],[45.0,155.0],[50.0,175.0],[51.5,-170.0],[51.0,-150.0],[49.0,-132.0],[48.4,-124.8]],
|
||||
// Transatlantic
|
||||
[[49.9,-2.3],[49.4,-6.4],[47.5,-20.0],[44.5,-40.0],[41.5,-60.0],[40.45,-73.8]],
|
||||
// US East coast → Panama → west coast S America
|
||||
[[40.45,-73.8],[35.2,-74.6],[28.0,-77.5],[24.0,-74.5],[20.2,-73.8],[15.0,-77.0],[9.4,-79.9],[8.9,-79.5],[7.2,-80.2],[1.0,-81.3],[-6.0,-81.5],[-12.0,-77.3]],
|
||||
// Panama → LA
|
||||
[[8.9,-79.5],[10.5,-87.5],[15.0,-96.5],[20.0,-106.0],[23.0,-110.6],[29.0,-116.0],[33.7,-118.4]],
|
||||
// Gulf of Mexico → Florida → NY
|
||||
[[29.3,-94.7],[26.5,-90.0],[24.3,-84.0],[24.2,-81.0],[26.5,-79.7],[31.0,-79.5],[35.2,-74.6]],
|
||||
// Persian Gulf → Hormuz → Arabian Sea → Gulf of Aden
|
||||
[[29.4,48.6],[27.3,50.8],[26.5,54.0],[26.5,56.4],[25.2,57.5],[22.6,60.3],[17.0,57.0],[12.2,52.0]],
|
||||
// Persian Gulf → Malacca
|
||||
[[25.2,57.5],[22.6,60.3],[17.0,66.0],[10.0,73.0],[6.6,79.3],[5.7,81.0]],
|
||||
// Cape route
|
||||
[[22.6,60.3],[10.0,58.0],[-5.0,52.0],[-12.0,45.5],[-20.0,39.5],[-28.0,34.5],[-34.5,26.0],[-35.3,19.5],[-33.0,15.5],[-24.0,11.0],[-10.0,4.0],[2.0,-3.0],[10.0,-18.5],[20.0,-19.5],[28.5,-15.0],[35.5,-10.0],[36.6,-9.6]],
|
||||
// Singapore → Australia (Lombok)
|
||||
[[1.25,104.0],[-2.0,107.4],[-6.0,113.0],[-8.7,115.75],[-14.0,117.0],[-20.3,118.6]],
|
||||
// SE Asia → Hong Kong → Busan
|
||||
[[22.0,116.0],[25.0,121.8],[29.0,124.0],[33.0,127.8],[35.0,129.1]],
|
||||
// North Sea → Baltic
|
||||
[[52.0,3.95],[53.8,6.8],[56.2,7.6],[57.9,10.7],[56.4,12.0],[55.6,12.75],[55.1,14.8],[56.5,18.6],[58.3,20.6],[59.6,23.5],[59.95,27.5],[59.9,29.8]],
|
||||
// Med: Port Said → Piraeus → Bosphorus → Novorossiysk
|
||||
[[31.4,32.33],[33.5,28.5],[36.0,25.3],[37.9,23.55],[39.2,24.6],[40.2,26.2],[40.95,28.9],[41.25,29.1],[42.8,31.5],[44.3,36.5],[44.65,37.8]],
|
||||
// W Med: Gibraltar → Marseille → Genoa
|
||||
[[35.95,-5.6],[37.0,-1.0],[39.5,2.6],[42.6,4.6],[43.2,5.3],[43.6,7.5],[44.3,8.9]],
|
||||
// China coast → Japan
|
||||
[[31.0,122.6],[33.0,126.0],[34.0,130.8],[33.6,135.0],[34.6,139.9]],
|
||||
// Australia east coast
|
||||
[[-33.85,151.4],[-30.0,153.8],[-27.3,153.6],[-24.0,152.8],[-19.0,147.8],[-12.0,143.8],[-10.4,142.1],[-9.8,137.0],[-8.6,125.0],[-8.7,115.75]],
|
||||
// Brazil → West Africa
|
||||
[[-23.9,-46.3],[-22.9,-41.0],[-15.0,-37.5],[-5.0,-34.4],[2.0,-25.0],[6.0,-8.0],[6.3,3.4]],
|
||||
];
|
||||
const LANE_V = LANES.map((l) => { const pts = l.map(([la, lo]) => geo.vec(la, lo)); const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.acos(clamp(v3.dot(pts[i - 1], pts[i]), -1, 1))); return { pts, cum, len: cum[cum.length - 1], raw: l }; });
|
||||
const SHIPS = [];
|
||||
(function genShips() {
|
||||
const NAMES1 = ['Northern','Aurora','Pacific','Silver','Atlas','Coral','Ocean','Polar','Cape','Golden','Blue','Iron','Jade','Amber','Crimson','Morning','Evening','Sea','Star','Harbor'];
|
||||
const NAMES2 = ['Lark','Spirit','Voyager','Pioneer','Horizon','Dawn','Tide','Meridian','Wave','Crest','Falcon','Unity','Glory','Venture','Breeze','Pearl','Fortune','Harmony','Trader','Express'];
|
||||
const kinds = ['container','tanker','bulk','LNG','ro-ro','general'];
|
||||
const tot = LANE_V.reduce((s, l) => s + l.len, 0);
|
||||
for (let i = 0; i < 2200; i++) {
|
||||
let r = R() * tot, li = 0; for (; li < LANE_V.length - 1; li++) { if (r < LANE_V[li].len) break; r -= LANE_V[li].len; }
|
||||
const kn = kinds[R.int(kinds.length)];
|
||||
const kts = kn === 'container' ? R.range(14, 21) : R.range(10, 15);
|
||||
SHIPS.push({ lane: li, off: R(), dir: R() < 0.5 ? 1 : -1, speed: (kts * 1.852) / EARTH_KM / 3600, lat0: R.gauss() * 0.12, name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: kn, mmsi: 200000000 + R.int(500000000), dark: R() < 0.012 });
|
||||
}
|
||||
// anchorage queues: Suez south & north, Singapore, Rotterdam, LA/LB
|
||||
const Q = [[29.72,32.56,48,0.09,'suez'],[31.45,32.2,30,0.1,'suez'],[1.22,103.9,90,0.12,'sin'],[52.0,3.6,26,0.12,'rtm'],[33.55,-118.2,22,0.08,'lalb'],[29.1,48.6,18,0.15,''],[22.2,113.9,40,0.12,'']];
|
||||
for (const [la, lo, n, s, q] of Q) for (let i = 0; i < n; i++) SHIPS.push({ anchor: [la + R.gauss() * s * 0.6, lo + R.gauss() * s], name: 'MV ' + R.pick(NAMES1) + ' ' + R.pick(NAMES2), kind: R.pick(kinds), mmsi: 200000000 + R.int(500000000), queue: q, speed: 0 });
|
||||
})();
|
||||
function shipAt(s, t) {
|
||||
if (s.anchor) { const [la, lo] = s.anchor; return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: hash01(s.mmsi) * TAU, sog: 0 }; }
|
||||
const L = LANE_V[s.lane];
|
||||
let d = (s.off * L.len + s.dir * s.speed * t) % L.len; if (d < 0) d += L.len;
|
||||
let i = 1; while (i < L.cum.length - 1 && L.cum[i] < d) i++;
|
||||
const seg = L.cum[i] - L.cum[i - 1] || 1; const p = (d - L.cum[i - 1]) / seg;
|
||||
let v = geo.slerp(L.pts[i - 1], L.pts[i], p);
|
||||
const [la0, lo0] = geo.ll(v);
|
||||
const brg = geo.bearing(...L.raw[i - 1], ...L.raw[i]) + (s.dir < 0 ? Math.PI : 0);
|
||||
const [la, lo] = geo.dest(la0, lo0, brg + Math.PI / 2, s.lat0 * 18);
|
||||
return { lat: la, lon: lo, v: geo.vec(la, lo), hdg: brg, sog: s.speed * EARTH_KM * 3600 / 1.852 };
|
||||
}
|
||||
|
||||
/* ---------- satellites (circular orbits; GPU-propagated) ---------- */
|
||||
const MU = 398600.4418;
|
||||
const SATS = [];
|
||||
function addSat(o) { const a = EARTH_KM + o.alt; o.n = Math.sqrt(MU / (a * a * a)); SATS.push(o); return o; }
|
||||
(function genSats() {
|
||||
// Starlink-like shells (53°, 550 km), (70°, 570 km), (97.6°, 560 km)
|
||||
const shells = [[53, 550, 22, 36], [70, 570, 12, 20], [97.6, 560, 10, 18], [43, 540, 14, 22]];
|
||||
for (const [inc, alt, planes, per] of shells) for (let p = 0; p < planes; p++) for (let k = 0; k < per; k++)
|
||||
addSat({ name: 'STARLINK-' + (1000 + SATS.length), inc: inc * D2R, raan: (p / planes) * TAU, u0: (k / per) * TAU + p * 0.13, alt: alt + R.range(-4, 4), kind: 0 });
|
||||
// other LEO (EO, weather, cubesats)
|
||||
for (let i = 0; i < 420; i++) addSat({ name: 'LEO-' + (40000 + i), inc: (R() < 0.5 ? R.range(96.5, 99) : R.range(30, 82)) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(380, 1200), kind: 1 });
|
||||
// GNSS MEO
|
||||
for (let i = 0; i < 31; i++) addSat({ name: 'GPS ' + (i + 1), inc: 55 * D2R, raan: (Math.floor(i / 5.2) / 6) * TAU, u0: (i % 6) / 6 * TAU + i, alt: 20180, kind: 2 });
|
||||
for (let i = 0; i < 24; i++) addSat({ name: 'GNSS-E ' + (i + 1), inc: 56 * D2R, raan: (i % 3) / 3 * TAU, u0: i / 8 * TAU, alt: 23222, kind: 2 });
|
||||
// GEO belt
|
||||
for (let i = 0; i < 90; i++) addSat({ name: 'GEO-' + (i + 1), inc: R.range(0, 0.08) * D2R, raan: 0, u0: R() * TAU, alt: 35786, kind: 3, geo: true });
|
||||
// debris
|
||||
for (let i = 0; i < 600; i++) addSat({ name: 'DEB ' + (20000 + i), inc: R.range(60, 100) * D2R, raan: R() * TAU, u0: R() * TAU, alt: R.range(650, 1500), kind: 4 });
|
||||
// Starlink train (fresh launch, low and tight)
|
||||
for (let i = 0; i < 24; i++) addSat({ name: 'STARLINK-T' + (i + 1), inc: 43 * D2R, raan: 1.9, u0: 2.4 + i * 0.006, alt: 312, kind: 5 });
|
||||
})();
|
||||
const ISS = addSat({ name: 'ISS (ZARYA)', inc: 51.64 * D2R, raan: 4.1, u0: 0.7, alt: 418, kind: 6 });
|
||||
const CSS = addSat({ name: 'CSS (TIANHE)', inc: 41.47 * D2R, raan: 2.2, u0: 3.1, alt: 385, kind: 6 });
|
||||
function satAt(s, t, gmst) { // ECEF unit vector with altitude
|
||||
const u = s.u0 + s.n * t, r = 1 + s.alt / EARTH_KM;
|
||||
const cu = Math.cos(u), su = Math.sin(u), cO = Math.cos(s.raan), sO = Math.sin(s.raan), ci = Math.cos(s.inc), si = Math.sin(s.inc);
|
||||
let x = cu * cO - su * ci * sO, y = cu * sO + su * ci * cO, z = su * si;
|
||||
if (s.geo) { const g = gmst; const c = Math.cos(s.u0), sn = Math.sin(s.u0); return [r * c, r * sn, z * r]; }
|
||||
const cg = Math.cos(-gmst), sg = Math.sin(-gmst);
|
||||
return [r * (x * cg - y * sg), r * (x * sg + y * cg), r * z];
|
||||
}
|
||||
|
||||
/* ---------- quakes (illustrative, along plate boundaries) ---------- */
|
||||
const QUAKE_ZONES = [[35.5,141.9],[38.3,142.4],[-6,130],[-20,-70],[-33,-72],[13,-90],[61,-148],[52,-170],[-41,174],[36,71],[28,84],[38,26],[-17,168],[-5,152],[18,-68],[40,30],[37,-121],[-1,-77],[14,121],[24,123]];
|
||||
const QUAKES = [];
|
||||
for (let i = 0; i < 46; i++) {
|
||||
const z = R.pick(QUAKE_ZONES);
|
||||
QUAKES.push({ lat: z[0] + R.gauss() * 1.6, lon: z[1] + R.gauss() * 1.6, mag: +(2.6 + Math.pow(R(), 2.6) * 3.2).toFixed(1), t: R.range(-72, 0) * 3600, depth: Math.round(R.range(8, 120)) });
|
||||
}
|
||||
const BIGQ = { lat: 35.62, lon: 141.95, mag: 6.4, t: -5.4 * 3600, depth: 32, place: 'off the coast of Honshu' };
|
||||
QUAKES.push(BIGQ);
|
||||
for (let i = 0; i < 9; i++) QUAKES.push({ lat: BIGQ.lat + R.gauss() * 0.35, lon: BIGQ.lon + R.gauss() * 0.4, mag: +(3.4 + R() * 1.6).toFixed(1), t: BIGQ.t + R.range(0.2, 5) * 3600, depth: Math.round(R.range(10, 50)) });
|
||||
|
||||
/* ---------- fires (FIRMS-like hotspots; start times so the ribbon shows spread) ---------- */
|
||||
const FIRE_CLUSTERS = [
|
||||
[40.2,-8.1,'Central Portugal',90,1.0],[42.4,-7.4,'Galicia',60,0.9],[38.1,23.4,'Attica',50,0.7],[39.8,21.0,'Epirus',30,0.5],[36.8,30.6,'Antalya',40,0.6],
|
||||
[34.4,-118.5,'Los Angeles County',45,0.8],[39.7,-121.6,'Butte County',40,0.6],[-14.0,-60.0,'Rondônia',180,1.6],[-9.5,24.0,'Katanga savanna',260,2.6],
|
||||
[8.5,19.0,'Central African savanna',220,2.6],[-17.0,131.0,'Northern Territory',140,2.0],[61.5,128.0,'Yakutia',60,1.3],[-2.5,113.5,'Kalimantan',70,1.0],
|
||||
[21.0,98.0,'Shan State',60,1.2],[-22.0,-55.0,'Mato Grosso do Sul',70,1.2],
|
||||
];
|
||||
const FIRES = [];
|
||||
for (const [la, lo, name, n, s] of FIRE_CLUSTERS) {
|
||||
const tStart = R.range(-70, -6) * 3600;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const rad = Math.sqrt(R()) * s, ang = R() * TAU;
|
||||
// fronts move with prevailing wind: later ignitions sit downwind
|
||||
const tt = tStart + (rad / s) * R.range(20, 110) * 3600;
|
||||
FIRES.push({ lat: la + Math.sin(ang) * rad * 0.8, lon: lo + Math.cos(ang) * rad, t: tt, frp: R.range(5, 120), cluster: name });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- tropical cyclone (illustrative name) ---------- */
|
||||
const CYCLONE = {
|
||||
name: 'Typhoon Halcyon', designation: '26W (illustrative)',
|
||||
// [hoursFromNow, lat, lon, windKmh, pressure hPa]
|
||||
track: [[-72,11.2,141.8,95,992],[-60,11.9,139.6,110,985],[-48,12.6,137.4,130,975],[-36,13.4,135.3,150,965],[-24,14.3,133.1,170,955],[-12,15.2,130.9,180,948],[0,16.2,128.8,185,944],
|
||||
[12,17.3,126.9,195,938],[24,18.6,125.1,200,935],[36,20.1,123.6,190,940],[48,21.8,122.5,175,950],[72,25.0,121.9,140,968],[96,28.6,124.4,110,980],[120,32.4,129.0,85,990]],
|
||||
at(hours) {
|
||||
const tr = this.track; if (hours <= tr[0][0]) return { lat: tr[0][1], lon: tr[0][2], wind: tr[0][3], p: tr[0][4] };
|
||||
for (let i = 1; i < tr.length; i++) if (hours <= tr[i][0]) {
|
||||
const a = tr[i - 1], b = tr[i], f = (hours - a[0]) / (b[0] - a[0]);
|
||||
return { lat: lerp(a[1], b[1], f), lon: lerp(a[2], b[2], f), wind: lerp(a[3], b[3], f), p: lerp(a[4], b[4], f) };
|
||||
}
|
||||
const z = tr[tr.length - 1]; return { lat: z[1], lon: z[2], wind: z[3], p: z[4] };
|
||||
},
|
||||
cone(hours) { return hours <= 0 ? 0 : 35 + hours * 3.1; }, // km radius of uncertainty
|
||||
};
|
||||
|
||||
/* ---------- cities (search + scatter) ---------- */
|
||||
const CITIES = [
|
||||
['New York',40.7128,-74.006],['London',51.5072,-0.1276],['Tokyo',35.6762,139.6503],['Paris',48.8566,2.3522],['Shanghai',31.2304,121.4737],['Mumbai',19.076,72.8777],
|
||||
['São Paulo',-23.5505,-46.6333],['Mexico City',19.4326,-99.1332],['Cairo',30.0444,31.2357],['Lagos',6.5244,3.3792],['Istanbul',41.0082,28.9784],['Moscow',55.7558,37.6173],
|
||||
['Seoul',37.5665,126.978],['Jakarta',-6.2088,106.8456],['Delhi',28.6139,77.209],['Los Angeles',34.0522,-118.2437],['Chicago',41.8781,-87.6298],['Singapore',1.3521,103.8198],
|
||||
['Sydney',-33.8688,151.2093],['Dubai',25.2048,55.2708],['Hong Kong',22.3193,114.1694],['Berlin',52.52,13.405],['Madrid',40.4168,-3.7038],['Rome',41.9028,12.4964],
|
||||
['Bangkok',13.7563,100.5018],['Toronto',43.6532,-79.3832],['San Francisco',37.7749,-122.4194],['Buenos Aires',-34.6037,-58.3816],['Johannesburg',-26.2041,28.0473],['Nairobi',-1.2921,36.8219],
|
||||
['Kathmandu',27.7172,85.324],['Lisbon',38.7223,-9.1393],['Athens',37.9838,23.7275],['Reykjavík',64.1466,-21.9426],['Tromsø',69.6492,18.9553],['Honolulu',21.3069,-157.8583],
|
||||
['Manila',14.5995,120.9842],['Taipei',25.033,121.5654],['Rotterdam',51.9244,4.4777],['Suez',29.9668,32.5498],['Cape Canaveral',28.3922,-80.6077],['Dothan',31.2232,-85.3905],
|
||||
];
|
||||
const CITY_V = CITIES.map(([n, la, lo]) => ({ name: n, lat: la, lon: lo }));
|
||||
|
||||
/* ---------- people (fictional handles) ---------- */
|
||||
const PEOPLE = {
|
||||
you: { h: 'you', name: 'You', hue: 205 },
|
||||
kora: { h: 'kora_maps', name: 'Kora M.', hue: 160 },
|
||||
tamang: { h: 'tamang_lens', name: 'Pemba T.', hue: 30 },
|
||||
rotor: { h: 'rotorwash_ret', name: 'J. Alder', hue: 95 },
|
||||
hydro: { h: 'hydro_nerd', name: 'Sita R.', hue: 200 },
|
||||
ais: { h: 'ais_owl', name: 'Marek', hue: 270 },
|
||||
sky: { h: 'skywatch_ana', name: 'Ana P.', hue: 330 },
|
||||
geo: { h: 'geodesy_dan', name: 'Dan K.', hue: 50 },
|
||||
crowd: { h: 'row_m_seat14', name: 'Lena', hue: 300 },
|
||||
news: { h: 'chopper_cam9', name: 'Desk 9', hue: 10 },
|
||||
ai: { h: 'claude', name: 'AI (proposal)', hue: 260, ai: true },
|
||||
};
|
||||
|
||||
Object.assign(GE, { AP, apByIata, FLIGHTS, flightAt, OPNAME, LANES, LANE_V, SHIPS, shipAt, SATS, ISS, CSS, satAt, QUAKES, BIGQ, FIRES, FIRE_CLUSTERS, CYCLONE, CITY_V, PEOPLE });
|
||||
@@ -0,0 +1,870 @@
|
||||
/* Hand-written WebGL2 renderer: ray-traced planet + single-scatter atmosphere, sprites, trails,
|
||||
lines, meshes (city/terrain/water) and a mobile post chain (half-res dual-Kawase bloom, FXAA-lite,
|
||||
ACES, grade, vignette, grain) with lens modes (cinematic / clear / night / thermal / NVG). */
|
||||
const GL = (GE.GL = {});
|
||||
|
||||
const GLSL_COMMON = `
|
||||
const float PI = 3.14159265;
|
||||
float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
|
||||
vec2 hash22(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*vec3(.1031,.1030,.0973)); p3 += dot(p3,p3.yzx+33.33); return fract((p3.xx+p3.yz)*p3.zy); }
|
||||
float hash13(vec3 p3){ p3 = fract(p3*.1031); p3 += dot(p3,p3.zyx+31.32); return fract((p3.x+p3.y)*p3.z); }
|
||||
float vnoise(vec2 p){ vec2 i=floor(p), f=fract(p); vec2 u=f*f*(3.-2.*f);
|
||||
return mix(mix(hash12(i),hash12(i+vec2(1,0)),u.x), mix(hash12(i+vec2(0,1)),hash12(i+vec2(1,1)),u.x), u.y); }
|
||||
float fbm(vec2 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise(p); p=p*2.03+vec2(1.7,9.2); a*=.5;} return s; }
|
||||
float vnoise3(vec3 p){ vec3 i=floor(p), f=fract(p); vec3 u=f*f*(3.-2.*f);
|
||||
float a=hash13(i), b=hash13(i+vec3(1,0,0)), c=hash13(i+vec3(0,1,0)), d=hash13(i+vec3(1,1,0));
|
||||
float e=hash13(i+vec3(0,0,1)), g=hash13(i+vec3(1,0,1)), h=hash13(i+vec3(0,1,1)), k=hash13(i+vec3(1,1,1));
|
||||
return mix(mix(mix(a,b,u.x),mix(c,d,u.x),u.y), mix(mix(e,g,u.x),mix(h,k,u.x),u.y), u.z); }
|
||||
float fbm3(vec3 p){ float s=0., a=.5; for(int i=0;i<4;i++){ s+=a*vnoise3(p); p=p*2.02+vec3(3.1,1.7,5.3); a*=.5;} return s; }
|
||||
`;
|
||||
|
||||
const GLSL_ATMO = `
|
||||
const float HR = 0.0021, HM = 0.0006, RA = 1.0165;
|
||||
const vec3 BR = vec3(22.1, 51.5, 126.3);
|
||||
const vec3 BM = vec3(16.0);
|
||||
uniform vec3 uSun;
|
||||
uniform float uSunI;
|
||||
vec2 raySphere(vec3 ro, vec3 rd, float r){ float b=dot(ro,rd); float c=dot(ro,ro)-r*r; float d=b*b-c; if(d<0.) return vec2(1e9,-1e9); d=sqrt(d); return vec2(-b-d,-b+d); }
|
||||
// optical depth (length units) of exp(-(r-1)/H) from radius r along zenith-cosine mu to infinity
|
||||
float odH(float r, float mu, float H){
|
||||
float c = sqrt(PI*0.5*r/H); float h=(r-1.)/H;
|
||||
if (mu >= 0.) return H*exp(-h)*c/(c*mu+1.);
|
||||
float r0 = r*sqrt(max(0.,1.-mu*mu));
|
||||
if (r0 < 1.0) return 1e4;
|
||||
float h0 = (r0-1.)/H; float c0 = sqrt(PI*0.5*r0/H);
|
||||
return H*(2.*exp(-h0)*c0 - exp(-h)*c/(-c*mu+1.));
|
||||
}
|
||||
vec3 sunTrans(vec3 x){ float r=length(x); float mu=dot(x,uSun)/r; return exp(-(BR*odH(r,mu,HR)+BM*1.11*odH(r,mu,HM))); }
|
||||
vec3 atmo(vec3 ro, vec3 rd, float tEnd, float inside, int steps, out vec3 T){
|
||||
vec2 ta = raySphere(ro, rd, RA);
|
||||
float t0 = max(ta.x, 0.), t1 = min(ta.y, tEnd);
|
||||
T = vec3(1.);
|
||||
if (t1 <= t0) return vec3(0.);
|
||||
float mu = dot(rd, uSun);
|
||||
float pR = 3./(16.*PI)*(1.+mu*mu);
|
||||
const float g = 0.76;
|
||||
float pM = 3./(8.*PI)*((1.-g*g)*(1.+mu*mu))/((2.+g*g)*pow(max(1.+g*g-2.*g*mu,1e-4),1.5));
|
||||
vec3 sR=vec3(0.), sM=vec3(0.); float oR=0., oM=0.; float L=t1-t0;
|
||||
float N = float(steps);
|
||||
for (int i=0;i<24;i++){
|
||||
if (i>=steps) break;
|
||||
float fa = float(i)/N, fb = float(i+1)/N;
|
||||
float ta0 = mix(fa, fa*fa, inside), tb0 = mix(fb, fb*fb, inside);
|
||||
float ds = (tb0-ta0)*L; float t = t0 + (ta0+tb0)*0.5*L;
|
||||
vec3 x = ro + rd*t; float r = length(x); float h=r-1.;
|
||||
float dR = exp(-h/HR), dM = exp(-h/HM);
|
||||
oR += dR*ds; oM += dM*ds;
|
||||
float muS = dot(x,uSun)/r;
|
||||
vec3 tau = BR*(oR+odH(r,muS,HR)) + BM*1.11*(oM+odH(r,muS,HM));
|
||||
vec3 att = exp(-tau);
|
||||
sR += dR*att*ds; sM += dM*att*ds;
|
||||
}
|
||||
T = exp(-(BR*oR + BM*1.11*oM));
|
||||
return uSunI*(sR*BR*pR + sM*BM*pM);
|
||||
}
|
||||
`;
|
||||
|
||||
const SRC = {};
|
||||
SRC.fsq_vs = `#version 300 es
|
||||
out vec2 vUv; out vec2 vNdc;
|
||||
void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2); vUv=p; vNdc=p*2.-1.; gl_Position=vec4(p*2.-1.,0.,1.); }`;
|
||||
|
||||
SRC.globe_fs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vNdc; out vec4 frag;
|
||||
${GLSL_COMMON}
|
||||
${GLSL_ATMO}
|
||||
uniform vec3 uCamR, uCamU, uCamB; uniform vec4 uTanS; uniform vec3 uEye; uniform float uEyeC; uniform float uT; uniform float uGmst;
|
||||
uniform sampler2D uDay, uNight, uClouds, uWater, uTopo;
|
||||
uniform vec4 uLens; // x mode, y night force, z cloud amount, w graticule
|
||||
uniform vec4 uCyc; // xyz center, w radius (rad)
|
||||
uniform vec4 uCycP; // x intensity, y spin, z eye radius
|
||||
uniform float uAurora;
|
||||
uniform mat3 uEnu; uniform vec3 uEyeLocal; uniform vec4 uCity; // x detail, y block m, z rot, w urban boost
|
||||
uniform float uRange; uniform int uSteps; uniform float uInside;
|
||||
uniform vec4 uFocus; // xyz focus dir, w strength (dim surroundings)
|
||||
uniform vec4 uLocalWater; // xy normal, z offset (m), w enabled
|
||||
uniform float uTraffic;
|
||||
vec2 uvOf(vec3 n){ return vec2(atan(n.y,n.x)/(2.*PI)+0.5, 0.5-asin(clamp(n.z,-1.,1.))/PI); }
|
||||
vec4 tex2(sampler2D s, vec2 uv){
|
||||
vec2 u2 = vec2(fract(uv.x+0.5)-0.5, uv.y);
|
||||
vec2 dx = dFdx(uv), dy = dFdy(uv), dx2 = dFdx(u2), dy2 = dFdy(u2);
|
||||
if (abs(dx2.x)+abs(dy2.x) < abs(dx.x)+abs(dy.x)) { dx = dx2; dy = dy2; }
|
||||
return textureGrad(s, uv, dx, dy);
|
||||
}
|
||||
vec3 stars(vec3 d){
|
||||
float c = cos(uGmst), s = sin(uGmst);
|
||||
d = vec3(c*d.x - s*d.y, s*d.x + c*d.y, d.z);
|
||||
vec3 col = vec3(0.);
|
||||
for (int L=0; L<3; L++){
|
||||
float sc = 60. + float(L)*70.;
|
||||
vec3 a = abs(d); vec2 uv; float face;
|
||||
if (a.x>a.y && a.x>a.z){ uv=d.yz/a.x; face=sign(d.x); }
|
||||
else if (a.y>a.z){ uv=d.xz/a.y; face=2.+sign(d.y); }
|
||||
else { uv=d.xy/a.z; face=4.+sign(d.z); }
|
||||
vec2 g = uv*sc; vec2 id = floor(g); vec2 f = fract(g)-0.5;
|
||||
vec2 rnd = hash22(id+face*17.+float(L)*31.);
|
||||
float b = pow(rnd.x, 70.) * (L==0 ? 1.4 : 0.8);
|
||||
vec2 off = (hash22(id*1.7+face)-0.5)*0.7;
|
||||
float dd = length(f-off);
|
||||
float w = fwidth(g.x)*0.9+0.02;
|
||||
col += b * smoothstep(w*1.25, 0., dd) * mix(vec3(1.,.84,.7), vec3(.75,.85,1.), rnd.y) * 4.;
|
||||
}
|
||||
// milky way band (galactic north pole RA 192.86°, Dec 27.13°)
|
||||
vec3 gnp = vec3(-0.8676, -0.1981, 0.4560);
|
||||
float gb = dot(d, gnp);
|
||||
float band = exp(-gb*gb*14.);
|
||||
float n = fbm3(d*6.)*0.7 + fbm3(d*17.)*0.3;
|
||||
col += band * smoothstep(0.35, 0.85, n) * vec3(0.10, 0.11, 0.15) * 0.9;
|
||||
col += band * vec3(0.012,0.013,0.02);
|
||||
return col;
|
||||
}
|
||||
float cycloneDensity(vec3 n){
|
||||
if (uCycP.x <= 0.) return 0.;
|
||||
float cd = dot(n, uCyc.xyz); if (cd < 0.985) return 0.;
|
||||
float d = acos(clamp(cd,-1.,1.));
|
||||
vec3 e1 = normalize(cross(vec3(0,0,1), uCyc.xyz)); vec3 e2 = cross(uCyc.xyz, e1);
|
||||
vec3 v = n - uCyc.xyz*cd; float th = atan(dot(v,e2), dot(v,e1));
|
||||
float x = d/uCyc.w;
|
||||
float sw = 2.*th + 5.2*log(max(x,0.03)) - uCycP.y;
|
||||
float arms = pow(0.5 + 0.5*sin(sw), 2.4);
|
||||
float tex = fbm(vec2(cos(th), sin(th))*x*9. + vec2(3.*log(x+0.05) - uCycP.y*0.2, uCycP.y*0.05));
|
||||
float dens = smoothstep(1.25, 0.28, x) * (0.12 + 0.88*arms) * (0.35 + 0.9*tex);
|
||||
float cdo = smoothstep(0.26, 0.08, x) * (0.75 + 0.25*tex);
|
||||
dens = max(dens, cdo);
|
||||
float eye = smoothstep(uCycP.z*0.8, uCycP.z*1.6, d) * (1. + 0.25*smoothstep(uCycP.z*2.6, uCycP.z*1.6, d));
|
||||
return clamp(dens*1.15, 0., 1.) * eye * uCycP.x;
|
||||
}
|
||||
vec3 aurora(vec3 n, float nightF){
|
||||
if (uAurora <= 0.) return vec3(0.);
|
||||
vec3 mp = vec3(0.0484, -0.1559, 0.9866);
|
||||
float s = dot(n, mp); float hemi = sign(s);
|
||||
float ml = asin(abs(s)) * 57.2958;
|
||||
float band = 66.5 - uAurora*5.5;
|
||||
float lonm = atan(dot(n, normalize(cross(mp, vec3(0,0,1)))), dot(n, normalize(cross(mp, cross(mp, vec3(0,0,1))))));
|
||||
float curt = 0.45 + 0.55*fbm(vec2(lonm*9. + uT*0.0006, uT*0.00025));
|
||||
float g = exp(-pow((ml-band)/(2.2+uAurora*0.6), 2.)) * curt;
|
||||
float r = exp(-pow((ml-band-3.5)/3.5, 2.)) * 0.25;
|
||||
return (vec3(0.12, 1.0, 0.5)*g + vec3(0.75, 0.15, 0.45)*r*0.45) * nightF * uAurora * 1.15;
|
||||
}
|
||||
vec3 cityDetail(vec3 base, vec2 q, float urban, float night, float fw, out vec3 emis){
|
||||
emis = vec3(0.);
|
||||
float rot = uCity.z; mat2 m = mat2(cos(rot), -sin(rot), sin(rot), cos(rot));
|
||||
vec2 p = m*q; float B = uCity.y;
|
||||
vec2 g = p/B; vec2 id = floor(g); vec2 f = fract(g);
|
||||
float big = step(0.8, hash12(floor(id/5.)+3.));
|
||||
float rw = 0.085 + 0.05*step(4.5, mod(id.x,5.)) ;
|
||||
vec2 e = min(f, 1.-f);
|
||||
float road = 1. - smoothstep(rw*0.75, rw, min(e.x, e.y));
|
||||
float aa = clamp(fw/B*4., 0., 1.);
|
||||
road = mix(road, 0.18, aa);
|
||||
float h = hash12(id);
|
||||
vec3 roof = mix(vec3(0.20,0.20,0.21), vec3(0.42,0.38,0.33), h);
|
||||
roof = mix(roof, vec3(0.30,0.36,0.26), step(0.86, h)); // parks
|
||||
float lot = vnoise(p*0.11)*0.12;
|
||||
vec3 urb = mix(roof*(0.9+lot), vec3(0.075,0.078,0.085), road);
|
||||
// rural: patchwork fields & forest
|
||||
vec2 fid = floor(p/(B*2.6)); float fh = hash12(fid+7.);
|
||||
vec3 field = base * mix(vec3(0.9,1.05,0.8), vec3(1.25,1.1,0.75), fh) * (0.85+0.3*vnoise(p*0.02));
|
||||
float forest = smoothstep(0.45,0.62, fbm(p*0.0016+3.));
|
||||
field = mix(field, base*vec3(0.55,0.75,0.5), forest);
|
||||
vec3 col = mix(field, urb, urban);
|
||||
// street lights at night along roads
|
||||
float lamps = road * urban * (0.5+0.5*hash12(floor(p/9.)));
|
||||
emis = vec3(1.0,0.58,0.24)*lamps*night*1.1*(1.-aa*0.55);
|
||||
if (uTraffic > 0.5) {
|
||||
vec2 rp = p/B; float along = abs(f.x-0.5) < abs(f.y-0.5) ? rp.y : rp.x;
|
||||
float cars = smoothstep(0.75, 1., sin(along*38. - uT*2.5 + h*20.)) * road * urban * (1.-aa);
|
||||
emis += mix(vec3(1.,0.15,0.1), vec3(1.,0.95,0.8), step(0.5, fract(along*3.)))*cars*(0.25 + night*1.4);
|
||||
}
|
||||
float lit = step(0.72, hash12(id*3.1)) * (1.-road) * urban * night;
|
||||
emis += vec3(0.9,0.8,0.6)*lit*0.035;
|
||||
return col;
|
||||
}
|
||||
void main(){
|
||||
vec3 rd = normalize(uCamR*(vNdc.x - uTanS.z)*uTanS.x + uCamU*(vNdc.y - uTanS.w)*uTanS.y - uCamB);
|
||||
vec3 ro = uEye;
|
||||
float b = dot(ro, rd);
|
||||
float disc = b*b - uEyeC;
|
||||
int mode = int(uLens.x+0.5);
|
||||
float clear = mode==1 ? 1. : 0.;
|
||||
vec3 col; float heat = 0.;
|
||||
vec3 T; vec3 ins;
|
||||
float nightForce = uLens.y;
|
||||
if (disc > 0. && b < 0.) {
|
||||
float t = uEyeC / (-b + sqrt(disc));
|
||||
vec3 p = ro + rd*t; vec3 n = normalize(p);
|
||||
vec2 uv = uvOf(n);
|
||||
vec3 day = pow(tex2(uDay, uv).rgb, vec3(2.2));
|
||||
vec3 nightTex = tex2(uNight, uv).rgb;
|
||||
float water = tex2(uWater, uv).r;
|
||||
float muS = dot(n, uSun);
|
||||
// relief from topography
|
||||
vec2 tx = vec2(1./2048., 1./1024.);
|
||||
float h0 = texture(uTopo, uv).r;
|
||||
float hx = texture(uTopo, uv+vec2(tx.x,0.)).r - texture(uTopo, uv-vec2(tx.x,0.)).r;
|
||||
float hy = texture(uTopo, uv-vec2(0.,tx.y)).r - texture(uTopo, uv+vec2(0.,tx.y)).r;
|
||||
vec3 east = normalize(vec3(-n.y, n.x, 0.)+1e-6); vec3 north = cross(n, east);
|
||||
float bumpK = 2.2 * (1.-water);
|
||||
vec3 nb = normalize(n - (east*hx + north*hy)*bumpK/ max(cos(asin(n.z)),0.2) * 0.6);
|
||||
float lights = max(0., (nightTex.g*1.1 - nightTex.b*0.55));
|
||||
lights = pow(lights, 1.7) * 2.6;
|
||||
float urban = smoothstep(0.03, 0.25, lights) + uCity.w;
|
||||
// low-altitude synthetic ground detail
|
||||
vec3 emisD = vec3(0.);
|
||||
float nightF = 1. - smoothstep(-0.16, 0.06, muS);
|
||||
nightF = max(nightF, nightForce);
|
||||
if (uCity.x > 0.001) {
|
||||
vec3 rdl = transpose(uEnu) * rd; vec2 q = (uEyeLocal + rdl * (t*6371000.)).xy;
|
||||
if (uLocalWater.w > 0.) { float lw = smoothstep(uLocalWater.z-25., uLocalWater.z+25., dot(q, uLocalWater.xy)); water = max(water, lw*uCity.x); day = mix(day, vec3(0.012,0.03,0.045), lw*uCity.x); }
|
||||
float fw = length(fwidth(q));
|
||||
vec3 dcol = cityDetail(day, q, clamp(urban,0.,1.)*(1.-water), nightF, fw, emisD);
|
||||
float fade = uCity.x * (1.-smoothstep(150., 900., fw)) * (1.-water*0.9);
|
||||
day = mix(day, dcol, fade); emisD *= fade;
|
||||
}
|
||||
// clouds (+ cyclone)
|
||||
vec2 cuv = uv + vec2(uT*1.2e-7, 0.);
|
||||
vec3 wp = n*5. + vec3(uT*2e-6);
|
||||
cuv += (vec2(fbm3(wp), fbm3(wp+7.3))-0.5)*0.012;
|
||||
float cl = tex2(uClouds, cuv).r;
|
||||
float dK = smoothstep(1.2, 0.08, uRange);
|
||||
float base0 = smoothstep(0.1, 0.9, cl);
|
||||
if (dK > 0.) {
|
||||
vec3 q3 = n*420. + vec3(uT*1.5e-5, 0., uT*1e-5);
|
||||
float n1 = fbm3(q3), n2 = fbm3(q3*4.1 + 7.);
|
||||
float cells = smoothstep(0.62 - cl*0.55, 0.92 - cl*0.35, n1*0.62 + n2*0.38 + cl*0.18);
|
||||
cl = mix(base0, cells * smoothstep(0.03, 0.3, cl), dK);
|
||||
} else cl = base0;
|
||||
cl *= 0.94;
|
||||
cl = max(cl, cycloneDensity(n));
|
||||
float cloudFade = smoothstep(0.0012, 0.006, uRange);
|
||||
cl *= uLens.z * cloudFade * (1.-clear);
|
||||
vec3 st = sunTrans(p*1.0005);
|
||||
float shade = mix(1.0, 1.0-0.55*tex2(uClouds, cuv - vec2(uSun.y, -uSun.z)*0.0015).r*uLens.z*cloudFade, step(0.,muS));
|
||||
float diff = max(dot(nb, uSun), 0.);
|
||||
vec3 skyAmb = vec3(0.022,0.036,0.07)*smoothstep(-0.25,0.5,muS);
|
||||
vec3 irr = uSunI*(st*diff*shade + skyAmb);
|
||||
if (clear > 0.5) irr = vec3(uSunI*0.32);
|
||||
irr *= (1.-nightForce*0.97);
|
||||
irr += vec3(0.05,0.07,0.13)*nightF*(1.-clear);
|
||||
vec3 alb = day;
|
||||
vec3 surf = alb * irr / PI;
|
||||
// ocean specular
|
||||
if (water > 0.01) {
|
||||
vec3 v = -rd;
|
||||
vec3 nw = n;
|
||||
if (uRange < 0.05) { vec2 wq = uv*vec2(4000.,2000.)+uT*0.02; nw = normalize(n + (east*(vnoise(wq)-.5) + north*(vnoise(wq.yx+3.)-.5))*0.04); }
|
||||
vec3 hv = normalize(v + uSun);
|
||||
float nh = max(dot(nw, hv), 0.);
|
||||
float rough = 0.18;
|
||||
float a2 = rough*rough; float dd = nh*nh*(a2-1.)+1.; float D = a2/(PI*dd*dd);
|
||||
float F = 0.02 + 0.98*pow(1.-max(dot(v,hv),0.), 5.);
|
||||
vec3 spec = uSunI * st * D * F * max(dot(nw,uSun),0.) * 0.25;
|
||||
float fres = 0.02 + 0.98*pow(1.-max(dot(nw,v),0.), 5.);
|
||||
vec3 skyR = vec3(0.12,0.22,0.45)*uSunI*0.05*smoothstep(-0.2,0.4,muS);
|
||||
surf += (spec*(1.-clear) + skyR*fres) * water * (1.-cl);
|
||||
}
|
||||
// night lights + moonlit black-marble land
|
||||
vec3 lightCol = mix(vec3(1.0,0.66,0.32), vec3(1.0,0.86,0.64), smoothstep(0.4,1.4,lights));
|
||||
vec3 emis = lightCol * lights * 3.2 * nightF * (1. - uCity.x*0.94);
|
||||
emis += pow(nightTex, vec3(2.2)) * vec3(0.5,0.6,1.0) * 0.12 * nightF;
|
||||
emis += emisD;
|
||||
emis *= (1.-cl*0.82);
|
||||
// clouds lit
|
||||
vec3 cirr = uSunI*(st*(0.25+0.75*max(dot(n,uSun)*0.9+0.1,0.)) + skyAmb*2.);
|
||||
if (clear > 0.5) cirr = vec3(uSunI*0.3);
|
||||
vec3 ccol = vec3(0.78) * cirr/PI + vec3(0.008,0.01,0.018)*nightF + lightCol*lights*0.25*nightF;
|
||||
surf = mix(surf, ccol, cl);
|
||||
surf += emis;
|
||||
surf += aurora(n, nightF);
|
||||
// graticule (clear lens)
|
||||
if (uLens.w > 0.) {
|
||||
vec2 ll = vec2(atan(n.y,n.x), asin(n.z))*57.2958/15.;
|
||||
vec2 gg = abs(fract(ll+0.5)-0.5)/fwidth(ll);
|
||||
float lineG = 1.-min(min(gg.x,gg.y),1.);
|
||||
surf += vec3(0.35,0.5,0.7)*lineG*0.35*uLens.w;
|
||||
}
|
||||
// focus: gently dim surroundings of a situation
|
||||
if (uFocus.w > 0.) { float fd = acos(clamp(dot(n, uFocus.xyz),-1.,1.)); surf *= mix(1., 0.55, uFocus.w*smoothstep(0.08, 0.5, fd)); }
|
||||
ins = atmo(ro, rd, t, uInside, uSteps, T);
|
||||
if (clear > 0.5) { ins *= 0.35; T = mix(T, vec3(1.), 0.6); }
|
||||
col = surf*T + ins;
|
||||
// heat: land warm by day, cities warm, ocean cool, clouds cold
|
||||
heat = mix(0.32 + 0.38*diff*(1.-nightForce), 0.2 - 0.12*abs(n.z), water) + 0.25*clamp(lights,0.,1.);
|
||||
heat = mix(heat, 0.06, cl);
|
||||
} else {
|
||||
// sky / space
|
||||
ins = atmo(ro, rd, 1e9, uInside, uSteps, T);
|
||||
vec3 sp = stars(rd) * (1.-clear*0.6);
|
||||
float sd = dot(rd, uSun);
|
||||
vec3 sun = vec3(1.,0.96,0.9) * (smoothstep(0.99996, 0.99999, sd)*400. + pow(max(sd,0.),900.)*3. + pow(max(sd,0.),40.)*0.08) * (1.-clear);
|
||||
col = (sp + sun)*T + ins;
|
||||
// aurora curtains at the limb
|
||||
float tc = -b; vec3 cp = ro + rd*max(tc,0.); float hr = length(cp);
|
||||
if (uAurora > 0. && hr > 1.004 && hr < 1.05) {
|
||||
vec3 cn = normalize(cp); float nf = 1.-smoothstep(-0.2,0.0,dot(cn,uSun));
|
||||
col += aurora(cn, nf) * smoothstep(1.05,1.012,hr) * smoothstep(1.004,1.012,hr) * 0.3;
|
||||
}
|
||||
heat = 0.;
|
||||
}
|
||||
frag = vec4(col, heat);
|
||||
}`;
|
||||
|
||||
/* ---------- sprites ---------- */
|
||||
const SPRITE_VS_HEAD = `#version 300 es
|
||||
precision highp float;
|
||||
uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR;
|
||||
uniform float uPx; uniform float uT; uniform float uAltX; uniform float uSizeK; uniform float uGmst; uniform vec2 uView;
|
||||
uniform float uMinSize; uniform float uAlphaK; uniform vec3 uSun;
|
||||
out vec4 vCol; out float vGlyph; out float vAng; out float vSize; out float vSeed; out float vHeat;
|
||||
float occluded(vec3 p){
|
||||
vec3 E = uEyeH + uEyeL; vec3 d = p - E; float L = length(d); vec3 u = d/L;
|
||||
float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c;
|
||||
if (disc <= 0.) return 0.;
|
||||
float tn = -b - sqrt(disc);
|
||||
return (tn > 0. && tn < L) ? 1. : 0.;
|
||||
}
|
||||
vec4 project(vec3 p){ vec3 rel = (p - uEyeH) - uEyeL; vec4 c = uVP*vec4(rel,1.); if (c.z > c.w) c.z = c.w*0.99999; return c; }
|
||||
float screenAngle(vec3 p, vec3 dir, vec4 c0){
|
||||
vec3 rel = (p - uEyeH) - uEyeL; float s = length(rel)*0.01 + 1e-7;
|
||||
vec4 c1 = uVP*vec4(rel + dir*s, 1.);
|
||||
vec2 a = c0.xy/c0.w, bb = c1.xy/c1.w;
|
||||
return atan((bb.y-a.y)*uView.y, (bb.x-a.x)*uView.x);
|
||||
}
|
||||
vec3 headingDir(vec3 p, float hdg){ vec3 up = normalize(p); vec3 e = normalize(vec3(-up.y, up.x, 0.)+1e-7); vec3 nn = cross(up, e); return nn*cos(hdg) + e*sin(hdg); }
|
||||
`;
|
||||
SRC.sprite_vs = SPRITE_VS_HEAD + `
|
||||
in vec3 aPos; in vec4 aCol; in vec4 aPar; // size, glyph, heading, t0 (seconds; visible after)
|
||||
in vec4 aExt; // x: tEnd, y: grow, z: seed, w: heat
|
||||
void main(){
|
||||
vec3 p = aPos;
|
||||
float vis = step(aPar.w, uT) * step(uT, aExt.x);
|
||||
vec4 c = project(p);
|
||||
float occ = occluded(p);
|
||||
gl_Position = c;
|
||||
float age = uT - aPar.w;
|
||||
float grow = 1. + aExt.y * clamp(age/86400., 0., 2.);
|
||||
float sz = max(aPar.x * uSizeK * grow, uMinSize);
|
||||
gl_PointSize = vis * (1.-occ) * sz * uPx;
|
||||
vCol = aCol; vCol.a *= uAlphaK; vGlyph = aPar.y; vSize = sz*uPx; vSeed = aExt.z + age*0.0;
|
||||
vAng = aPar.z > -9. ? screenAngle(p, headingDir(p, aPar.z), c) : 0.;
|
||||
vHeat = aExt.w;
|
||||
if (aPar.y > 1.5 && aPar.y < 2.5) vSeed = age; // quake ring: pass age
|
||||
}`;
|
||||
SRC.flight_vs = SPRITE_VS_HEAD + `
|
||||
in vec3 aA; in vec3 aB; in vec4 aTime; in vec4 aMeta; // t0,dur,period,_ ; altKm, kind, seed, dKm
|
||||
uniform float uTrail; uniform float uTrailLen; in float aK;
|
||||
uniform vec4 uKindMask; uniform float uSelId; uniform vec4 uKindSize;
|
||||
out float vK;
|
||||
vec3 flightPos(float t, out float vis, out vec3 dir){
|
||||
float ph = mod(t - aTime.x, aTime.z);
|
||||
float hf = aTime.z*0.5; float leg = step(hf, ph); float lp = ph - leg*hf;
|
||||
vis = step(lp, aTime.y) * step(0., lp);
|
||||
float pr = clamp(lp/aTime.y, 0., 1.);
|
||||
vec3 A = mix(aA, aB, leg), B = mix(aB, aA, leg);
|
||||
float om = acos(clamp(dot(A,B), -1., 1.)); float so = max(sin(om), 1e-5);
|
||||
vec3 s = (sin((1.-pr)*om)*A + sin(pr*om)*B)/so;
|
||||
vec3 s2 = (sin((1.-pr-0.002)*om)*A + sin((pr+0.002)*om)*B)/so;
|
||||
dir = normalize(s2 - s);
|
||||
float climb = clamp(min(pr,1.-pr)*aMeta.w/160., 0., 1.);
|
||||
float alt = aMeta.x*sqrt(climb);
|
||||
return s*(1. + alt*uAltX/6371.);
|
||||
}
|
||||
void main(){
|
||||
int kind = int(aMeta.y+0.5);
|
||||
float km = kind==0?uKindMask.x: kind==1?uKindMask.y: kind==2?uKindMask.z: uKindMask.w;
|
||||
float vis; vec3 dir;
|
||||
float t = uT - aK*uTrailLen;
|
||||
vec3 p = flightPos(t, vis, dir);
|
||||
vec4 c = project(p);
|
||||
float occ = occluded(p);
|
||||
gl_Position = c;
|
||||
float sel = float(abs(float(gl_InstanceID) - uSelId) < 0.5);
|
||||
float ks = kind==0?uKindSize.x: kind==1?uKindSize.y: kind==2?uKindSize.z: uKindSize.w;
|
||||
float sz = max(ks*uSizeK*(1.+sel*0.8), uMinSize);
|
||||
gl_PointSize = vis*(1.-occ)*km*sz*uPx;
|
||||
vec3 colK = kind==0 ? vec3(0.42,0.74,1.0) : kind==1 ? vec3(1.0,0.56,0.3) : kind==2 ? vec3(0.55,1.0,0.5) : vec3(0.78,0.85,1.0);
|
||||
colK = mix(colK, vec3(1.0,0.86,0.45), sel);
|
||||
vCol = vec4(colK, vis*(1.-occ)*km*uAlphaK*(uTrail>0.5 ? pow(1.-aK,1.6)*(0.55+sel*0.45) : 1.));
|
||||
vK = aK;
|
||||
vGlyph = 1.; vSize = sz*uPx; vSeed = aMeta.z; vHeat = 0.55;
|
||||
vAng = screenAngle(p, dir, c);
|
||||
}`;
|
||||
SRC.sat_vs = SPRITE_VS_HEAD + `
|
||||
in vec4 aEl; in vec4 aSM; // inc, raan, u0, n ; altKm, kind, geo, seed
|
||||
uniform vec4 uSatMask0; uniform vec4 uSatMask1; uniform float uSelId;
|
||||
void main(){
|
||||
float u = aEl.z + aEl.w*uT; float r = 1. + aSM.x/6371.;
|
||||
float cu=cos(u), su=sin(u), cO=cos(aEl.y), sO=sin(aEl.y), ci=cos(aEl.x), si=sin(aEl.x);
|
||||
vec3 x = vec3(cu*cO - su*ci*sO, cu*sO + su*ci*cO, su*si);
|
||||
vec3 p;
|
||||
if (aSM.z > 0.5) p = vec3(cos(aEl.z), sin(aEl.z), 0.)*r;
|
||||
else { float cg=cos(-uGmst), sg=sin(-uGmst); p = r*vec3(x.x*cg - x.y*sg, x.x*sg + x.y*cg, x.z); }
|
||||
int k = int(aSM.y+0.5);
|
||||
float m = k==0?uSatMask0.x: k==1?uSatMask0.y: k==2?uSatMask0.z: k==3?uSatMask0.w: k==4?uSatMask1.x: k==5?uSatMask1.y: uSatMask1.z;
|
||||
vec4 c = project(p); float occ = occluded(p);
|
||||
gl_Position = c;
|
||||
float sel = float(abs(float(gl_VertexID) - uSelId) < 0.5);
|
||||
float base = k==6 ? 15. : k==5 ? 4.0 : k==3 ? 2.6 : k==4 ? 1.4 : 1.6;
|
||||
// sunlit satellites glint; in earth shadow they dim
|
||||
vec3 sd = uSun; float bb = dot(p, sd); float rho2 = dot(p,p) - bb*bb; float lit = (bb < 0. && rho2 < 1.) ? 0.25 : 1.;
|
||||
float sz = max(base*uSizeK*(1.+sel), uMinSize);
|
||||
gl_PointSize = (1.-occ)*m*sz*uPx;
|
||||
vec3 col = k==6 ? vec3(1.0,0.9,0.7) : k==5 ? vec3(0.95,0.9,1.0) : k==4 ? vec3(0.7,0.62,0.7) : k==3 ? vec3(0.85,0.7,1.0) : k==2 ? vec3(0.7,0.85,1.0) : vec3(0.79,0.66,1.0);
|
||||
vCol = vec4(col*lit, m*(1.-occ)*uAlphaK*(k==6||k==5 ? 1. : k==3 ? 0.7 : 0.42));
|
||||
vGlyph = k==6 ? 7. : 0.; vSize = sz*uPx; vSeed = aSM.w; vHeat = 0.2; vAng = 0.;
|
||||
}`;
|
||||
SRC.sun_uniform = `uniform vec3 uSun;`;
|
||||
|
||||
SRC.sprite_fs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec4 vCol; in float vGlyph; in float vAng; in float vSize; in float vSeed; in float vHeat;
|
||||
uniform float uT; uniform float uPass; uniform float uWall; uniform float uGlyphPx;
|
||||
out vec4 frag;
|
||||
${GLSL_COMMON}
|
||||
float sdSeg(vec2 p, vec2 a, vec2 b){ vec2 pa=p-a, ba=b-a; float h=clamp(dot(pa,ba)/dot(ba,ba),0.,1.); return length(pa-ba*h); }
|
||||
void main(){
|
||||
vec2 q = gl_PointCoord*2.-1.; q.y = -q.y;
|
||||
float r = length(q);
|
||||
int g = int(vGlyph+0.5);
|
||||
float px = 2.5/max(vSize,1.);
|
||||
float a = 0.; vec3 col = vCol.rgb; float glow = 0.;
|
||||
if (vSize < uGlyphPx && g != 2 && g != 11 && g != 10 && g != 9 && g != 6 && g != 14 && g != 12) g = 0;
|
||||
float ca = cos(vAng - 1.5708), sa = sin(vAng - 1.5708);
|
||||
vec2 rq = vec2(ca*q.x + sa*q.y, -sa*q.x + ca*q.y);
|
||||
if (g == 0) { a = exp(-r*r*7.)*1.0 + exp(-r*r*2.2)*0.18; }
|
||||
else if (g == 1) { // aircraft
|
||||
vec2 p = rq*1.15;
|
||||
float fus = sdSeg(p, vec2(0.,-0.72), vec2(0.,0.78)) - 0.085;
|
||||
float wing = max(abs(p.y + 0.02 + abs(p.x)*0.42) - 0.075, abs(p.x) - 0.86);
|
||||
float tail = max(abs(p.y + 0.66 + abs(p.x)*0.3) - 0.05, abs(p.x) - 0.34);
|
||||
float d = min(fus, min(wing, tail));
|
||||
a = (smoothstep(px, -px, d) + exp(-r*r*3.)*0.22)*0.85;
|
||||
}
|
||||
else if (g == 2) { // quake ring: vSeed = age (s)
|
||||
float age = vSeed; float ph = fract(uT/2.4);
|
||||
float rr = 0.25 + 0.7*ph;
|
||||
float pulse = exp(-pow((r-rr)/0.05,2.)) * (1.-ph) * step(age, 86400.*1.5);
|
||||
float base = exp(-pow((r-0.55)/0.035,2.))*0.55 + exp(-r*r*40.)*1.2;
|
||||
a = (pulse + base) * smoothstep(1.,0.9,r);
|
||||
}
|
||||
else if (g == 3) { // fire
|
||||
float fl = 0.7 + 0.3*sin(uT*7. + vSeed*30.) * sin(uT*3.1 + vSeed*11.);
|
||||
a = (exp(-r*r*9.)*1.3 + exp(-r*r*2.5)*0.35) * fl;
|
||||
col = mix(vec3(1.0,0.35,0.08), vec3(1.0,0.85,0.45), exp(-r*r*14.));
|
||||
}
|
||||
else if (g == 4) { // ship hull
|
||||
vec2 p = rq*1.2;
|
||||
float d = max(abs(p.x) - 0.26 + max(p.y-0.35,0.)*0.55, abs(p.y) - 0.8);
|
||||
a = smoothstep(px, -px, d) + exp(-r*r*4.)*0.15;
|
||||
}
|
||||
else if (g == 5) { // camera eye
|
||||
float ring = abs(r - 0.62) - 0.1;
|
||||
float lid = abs(length(q*vec2(1.,1.9)) - 0.86) - 0.07;
|
||||
float d = min(ring, r - 0.26);
|
||||
a = smoothstep(px, -px, min(d, lid)) * 0.95 + exp(-r*r*3.)*0.18;
|
||||
}
|
||||
else if (g == 6) { // signed capture / hologram seal
|
||||
float dia = abs(q.x)+abs(q.y);
|
||||
float d = min(abs(dia - 0.62) - 0.09, dia - 0.22);
|
||||
float beam = exp(-q.x*q.x*80.) * smoothstep(-1., 0., -q.y) * 0.0;
|
||||
a = smoothstep(px, -px, d) + exp(-r*r*3.)*0.35 + beam;
|
||||
}
|
||||
else if (g == 7) { // satellite / station
|
||||
vec2 p = q*1.25;
|
||||
float body = max(abs(p.x)-0.16, abs(p.y)-0.16);
|
||||
float panel = max(abs(abs(p.x)-0.58)-0.3, abs(p.y)-0.13);
|
||||
float truss = max(abs(p.x)-0.9, abs(p.y)-0.03);
|
||||
a = smoothstep(px, -px, min(min(body,panel),truss)) + exp(-r*r*3.)*0.35;
|
||||
}
|
||||
else if (g == 8) { // helicopter
|
||||
vec2 p = rq*1.2;
|
||||
float rotor = abs(r*1.2 - 0.82) - 0.03;
|
||||
float spin = abs(sin(atan(q.y,q.x)*2. + uT*40.));
|
||||
float blades = (r < 0.95) ? smoothstep(0.9, 1., spin) * 0.55 : 0.;
|
||||
float body = length(p*vec2(1.7,1.)) - 0.32;
|
||||
float boom = sdSeg(p, vec2(0.,-0.1), vec2(0.,-0.82)) - 0.05;
|
||||
a = max(smoothstep(px, -px, min(body, boom)), blades) + smoothstep(px,-px,rotor)*0.35 + exp(-r*r*3.)*0.18;
|
||||
}
|
||||
else if (g == 9) { // situation pin
|
||||
float ph = fract(uT*0.5 + vSeed);
|
||||
a = smoothstep(px,-px, abs(r-0.42)-0.07) + exp(-r*r*30.)*1.4 + exp(-pow((r-0.4-ph*0.55)/0.06,2.))*(1.-ph)*0.8;
|
||||
}
|
||||
else if (g == 10) { // hex
|
||||
vec2 p = abs(q); float d = max(p.x*0.866 + p.y*0.5, p.y) - 0.86;
|
||||
a = smoothstep(px*2., -px*2., d) * 0.5 + smoothstep(px,-px, abs(d+0.02)-0.03)*0.6;
|
||||
}
|
||||
else if (g == 11) { // soft blob (smoke / air quality / radar)
|
||||
float n = vnoise(q*2.5 + vSeed*13. + uT*0.02);
|
||||
a = exp(-r*r*2.6) * (0.55+0.45*n);
|
||||
}
|
||||
else if (g == 12) { // lightning
|
||||
float fl = step(0.93, fract(sin(floor(uT*3.+vSeed*40.)*91.7)*437.1));
|
||||
a = (exp(-r*r*18.)*2. + exp(-min(abs(q.x),abs(q.y))*30.)*exp(-r*2.)*0.8) * fl;
|
||||
}
|
||||
else if (g == 13) { // infra square
|
||||
vec2 p = abs(q); float d = max(p.x,p.y) - 0.55;
|
||||
a = smoothstep(px,-px, abs(d)-0.09) + smoothstep(px,-px,max(p.x,p.y)-0.18) + exp(-r*r*3.)*0.15;
|
||||
}
|
||||
else if (g == 14) { // volcano triangle
|
||||
vec2 p = q; p.y += 0.2;
|
||||
float d = max(abs(p.x)*0.866 + p.y*0.5, -p.y) - 0.45;
|
||||
a = smoothstep(px,-px, abs(d)-0.08) + exp(-r*r*3.)*0.2;
|
||||
}
|
||||
else if (g == 15) { // wind streak (oriented)
|
||||
a = exp(-(rq.x*rq.x*60. + rq.y*rq.y*1.6)) * smoothstep(1.,0.4,abs(rq.y));
|
||||
}
|
||||
else if (g == 16) { // runner / small dot with ring
|
||||
a = smoothstep(px,-px, r-0.42) + smoothstep(px,-px, abs(r-0.7)-0.06)*0.5;
|
||||
}
|
||||
else if (g == 17) { // drone (quad)
|
||||
vec2 p = abs(rq);
|
||||
float arms = sdSeg(p, vec2(0.), vec2(0.5,0.5)) - 0.06;
|
||||
float rot = abs(length(p - vec2(0.55)) - 0.22) - 0.05;
|
||||
a = smoothstep(px,-px, min(arms, rot)) + exp(-r*r*4.)*0.2;
|
||||
}
|
||||
if (r > 1.) a = 0.;
|
||||
a *= vCol.a;
|
||||
if (a < 0.003) discard;
|
||||
if (uPass > 0.5) frag = vec4(col, a*0.85); // alpha-blended pass
|
||||
else frag = vec4(col*a, vHeat*a); // additive pass
|
||||
}`;
|
||||
|
||||
/* ---------- lines (world space, polyline strips) ---------- */
|
||||
SRC.line_vs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec3 aPos; in vec4 aCol; in vec2 aD; // dist along (units), dashed flag
|
||||
uniform mat4 uVP; uniform vec3 uEyeH, uEyeL; uniform float uEyeC; uniform float uOccR; uniform float uAlphaK;
|
||||
out vec4 vCol; out vec2 vD;
|
||||
void main(){
|
||||
vec3 E = uEyeH + uEyeL; vec3 d = aPos - E; float L = length(d); vec3 u = d/L;
|
||||
float b = dot(E,u); float c = uEyeC + 1. - uOccR*uOccR; float disc = b*b - c; float occ = 0.;
|
||||
if (disc > 0.) { float tn = -b - sqrt(disc); occ = (tn > 0. && tn < L) ? 1. : 0.; }
|
||||
vec4 cp = uVP*vec4((aPos-uEyeH)-uEyeL, 1.);
|
||||
if (cp.z > cp.w) cp.z = cp.w*0.99999;
|
||||
gl_Position = cp;
|
||||
vCol = aCol; vCol.a *= (1.-occ*0.92)*uAlphaK; vD = aD;
|
||||
}`;
|
||||
SRC.line_fs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec4 vCol; in vec2 vD; uniform float uDashK; uniform float uT; out vec4 frag;
|
||||
void main(){
|
||||
float a = vCol.a;
|
||||
if (vD.y > 0.5 && fract(vD.x*uDashK - uT*0.0) > 0.55) discard;
|
||||
if (vD.y > 1.5) a *= 0.5 + 0.5*sin(vD.x*uDashK*6.2832 - uT*3.);
|
||||
frag = vec4(vCol.rgb*a, a*0.3);
|
||||
}`;
|
||||
|
||||
/* ---------- meshes (local ENU meters, camera-relative matrix) ---------- */
|
||||
SRC.mesh_vs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec3 aPos; in vec3 aNor; in vec2 aUV; in vec4 aAttr; // type, hash, height, misc
|
||||
uniform mat4 uMVP; uniform float uT; uniform vec4 uFlood; // x: tHours, y: start h, z: speed m/h, w: crest m
|
||||
out vec3 vPos; out vec3 vNor; out vec2 vUV; out vec4 vAttr;
|
||||
void main(){
|
||||
vec3 p = aPos;
|
||||
if (aAttr.x > 1.5 && aAttr.x < 2.5) { // flood water: rise with crest arrival
|
||||
float s = aUV.x; float arr = uFlood.y + s/uFlood.z;
|
||||
float dt = uFlood.x - arr;
|
||||
float crest = uFlood.w * (dt > 0. ? exp(-dt/5.) * smoothstep(0., 0.4, dt) : exp(-dt*dt*6.)) ;
|
||||
p.z += crest * 0.5;
|
||||
}
|
||||
vPos = p; vNor = aNor; vUV = aUV; vAttr = aAttr;
|
||||
gl_Position = uMVP*vec4(p, 1.);
|
||||
}`;
|
||||
SRC.mesh_fs = `#version 300 es
|
||||
precision highp float;
|
||||
in vec3 vPos; in vec3 vNor; in vec2 vUV; in vec4 vAttr;
|
||||
uniform vec3 uSunL; uniform vec3 uSunCol; uniform vec3 uSkyCol; uniform vec3 uFogCol; uniform vec3 uEyeL;
|
||||
uniform float uNight; uniform float uT; uniform float uVis; uniform vec4 uFlood; uniform float uClear;
|
||||
out vec4 frag;
|
||||
${GLSL_COMMON}
|
||||
void main(){
|
||||
int type = int(vAttr.x+0.5);
|
||||
vec3 n = normalize(vNor);
|
||||
vec3 v = normalize(uEyeL - vPos);
|
||||
float diff = max(dot(n, uSunL), 0.);
|
||||
vec3 base; vec3 emis = vec3(0.); float heat = 0.35; float spec = 0.;
|
||||
if (type == 0) { // building
|
||||
float h = vAttr.y;
|
||||
base = mix(vec3(0.34,0.35,0.37), vec3(0.62,0.57,0.5), h);
|
||||
base = mix(base, vec3(0.2,0.25,0.3), step(0.7, h)); // glass towers
|
||||
if (abs(n.z) < 0.5) {
|
||||
vec2 f = vec2(vUV.x/3.2, vUV.y/3.6);
|
||||
vec2 id = floor(f); vec2 fr = fract(f);
|
||||
float win = step(0.18, fr.x)*step(fr.x,0.82)*step(0.22,fr.y)*step(fr.y,0.78);
|
||||
float r = hash12(id + h*91.);
|
||||
float lit = step(1. - (0.12 + 0.5*uNight), r) * win * step(vUV.y, vAttr.z - 1.);
|
||||
vec3 wc = mix(vec3(1.0,0.72,0.42), vec3(0.75,0.85,1.0), step(0.6, hash12(id*1.3+h)));
|
||||
emis += wc * lit * (0.9 + 0.6*r) * uNight * 2.2;
|
||||
base = mix(base, base*0.45 + uSkyCol*0.08, win*0.8);
|
||||
spec = win*(1.-uNight)*0.6;
|
||||
} else base *= 0.82;
|
||||
float ao = smoothstep(0., 14., vUV.y + (1.-abs(n.z))*0.) * 0.35 + 0.65;
|
||||
if (abs(n.z) < 0.5) base *= ao;
|
||||
heat = 0.4 + 0.3*uNight;
|
||||
} else if (type == 1) { // terrain
|
||||
float hgt = vAttr.z; float slope = 1.-n.z;
|
||||
vec3 forest = vec3(0.035,0.075,0.025), meadow = vec3(0.15,0.17,0.07), rock = vec3(0.22,0.2,0.18), snow = vec3(0.85,0.88,0.93);
|
||||
float nz = vnoise(vPos.xy*0.004)*0.5 + vnoise(vPos.xy*0.02)*0.25 + vnoise(vPos.xy*0.09)*0.12;
|
||||
base = mix(forest, meadow, smoothstep(3000., 3700., hgt + nz*500.));
|
||||
base = mix(base, forest*0.8, smoothstep(0.2, 0.45, slope) * (1. - smoothstep(3200., 3800., hgt)));
|
||||
base = mix(base, rock, clamp(smoothstep(0.55, 0.85, slope + nz*0.25) + smoothstep(3900., 4500., hgt + nz*400.), 0., 1.));
|
||||
base = mix(base, snow, smoothstep(4700., 5100., hgt + nz*700. - slope*500.));
|
||||
// flood scar (mud) along the channel once the crest has passed
|
||||
float scar = vAttr.w; // 0..1 near channel
|
||||
float s = vUV.x; float arr = uFlood.y + s/uFlood.z;
|
||||
float passed = smoothstep(0., 0.6, uFlood.x - arr);
|
||||
base = mix(base, vec3(0.16,0.14,0.11), scar*passed*0.8);
|
||||
heat = 0.3 + 0.2*diff;
|
||||
} else if (type == 2) { // water (river / flood)
|
||||
float s = vUV.x; float across = abs(vUV.y);
|
||||
float arr = uFlood.y + s/uFlood.z; float dt = uFlood.x - arr;
|
||||
float w = 0.18 + 0.82*(dt > 0. ? exp(-dt/6.)*smoothstep(0., 0.3, dt) : exp(-dt*dt*5.));
|
||||
if (across > w) discard;
|
||||
float flow = vnoise(vec2(s*0.02 - uT*0.9, vUV.y*6.));
|
||||
base = mix(vec3(0.25,0.22,0.17), vec3(0.45,0.38,0.28), flow) * mix(1., 1.25, step(0.08, w-0.18));
|
||||
spec = 0.8; heat = 0.12;
|
||||
emis += vec3(0.9,0.8,0.6)*smoothstep(0.85,1.,flow)*0.06*(1.-uNight);
|
||||
} else if (type == 3) { // stadium seats + crowd
|
||||
base = vec3(0.16,0.17,0.2);
|
||||
float crowd = step(0.45, hash12(floor(vUV*vec2(1.6,1.2))));
|
||||
float flash = step(0.985, hash12(floor(vUV*vec2(1.6,1.2)) + floor(uT*2.)));
|
||||
emis += mix(vec3(0.5,0.6,1.0), vec3(1.0,0.95,0.9), flash) * crowd * (0.05 + flash*4.) * uNight;
|
||||
heat = 0.7;
|
||||
} else if (type == 4) { // flat pads, runways, pitch
|
||||
float h = vAttr.y;
|
||||
base = h > 0.5 ? vec3(0.06,0.2,0.05)*(0.85+0.15*step(0.5,fract(vUV.x/10.))) : vec3(0.12,0.12,0.13);
|
||||
if (h < 0.5) base += vec3(0.7)*step(0.97, fract(vUV.x/30.))*step(abs(vUV.y - vAttr.w*0.5), 0.6);
|
||||
float edge = step(vUV.y, 1.6) + step(vAttr.w - 1.6, vUV.y);
|
||||
float dots = step(0.93, fract(vUV.x/40.));
|
||||
emis += (h > 0.5 ? vec3(0.05,0.2,0.04)*0.8 : vec3(1.0,0.85,0.55)*dots*edge*2.5 + vec3(0.25,0.45,1.0)*step(0.96, fract((vUV.x+vUV.y)/23.))*0.6) * uNight;
|
||||
heat = 0.45;
|
||||
} else if (type == 5) { // stage / light rig / beacon
|
||||
base = vec3(0.05);
|
||||
emis = mix(vec3(0.9,0.3,1.0), vec3(0.3,0.8,1.0), 0.5+0.5*sin(uT*1.7+vPos.x*0.05)) * 3.0;
|
||||
heat = 0.9;
|
||||
} else { // houses
|
||||
base = mix(vec3(0.55,0.45,0.38), vec3(0.75,0.72,0.66), vAttr.y);
|
||||
if (n.z > 0.5) base = mix(vec3(0.45,0.2,0.15), vec3(0.3,0.35,0.42), step(0.5, vAttr.y));
|
||||
emis += vec3(1.0,0.7,0.4)*step(0.6, vAttr.y)*uNight*0.5*step(n.z,0.5);
|
||||
}
|
||||
vec3 amb = uSkyCol * (0.55 + 0.45*n.z);
|
||||
vec3 col = base * (uSunCol*diff + amb);
|
||||
if (spec > 0.) { vec3 hv = normalize(v + uSunL); col += uSunCol * pow(max(dot(n,hv),0.), 60.) * spec * 0.6; col += uSkyCol*spec*0.25*pow(1.-max(dot(n,v),0.),3.); }
|
||||
col += emis;
|
||||
float dist = length(uEyeL - vPos);
|
||||
float fog = 1. - exp(-dist / uVis);
|
||||
col = mix(col, uFogCol, clamp(fog, 0., 0.8));
|
||||
frag = vec4(col, heat);
|
||||
}`;
|
||||
|
||||
/* ---------- post ---------- */
|
||||
SRC.bright_fs = `#version 300 es
|
||||
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; uniform float uThr; out vec4 frag;
|
||||
void main(){
|
||||
vec3 c = vec3(0.);
|
||||
c += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb;
|
||||
c += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; c += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb;
|
||||
c *= 0.25;
|
||||
float l = max(c.r, max(c.g, c.b));
|
||||
float k = clamp(l - uThr*0.6, 0., uThr*0.8); k = k*k/(4.*uThr*0.8+1e-4);
|
||||
float w = max(k, l - uThr) / max(l, 1e-4);
|
||||
frag = vec4(min(c*w, vec3(60.)), 1.);
|
||||
}`;
|
||||
SRC.down_fs = `#version 300 es
|
||||
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform vec2 uTexel; out vec4 frag;
|
||||
void main(){
|
||||
vec3 s = texture(uSrc, vUv).rgb*4.;
|
||||
s += texture(uSrc, vUv + uTexel*vec2(-1.,-1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,-1.)).rgb;
|
||||
s += texture(uSrc, vUv + uTexel*vec2(-1.,1.)).rgb; s += texture(uSrc, vUv + uTexel*vec2(1.,1.)).rgb;
|
||||
frag = vec4(s/8., 1.);
|
||||
}`;
|
||||
SRC.up_fs = `#version 300 es
|
||||
precision highp float; in vec2 vUv; uniform sampler2D uSrc; uniform sampler2D uBase; uniform vec2 uTexel; uniform float uMix; out vec4 frag;
|
||||
void main(){
|
||||
vec3 s = vec3(0.); vec2 o = uTexel;
|
||||
s += texture(uSrc, vUv + vec2(-o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,o.y)).rgb*2.;
|
||||
s += texture(uSrc, vUv + vec2(0.,o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(o.x,o.y)).rgb*2.;
|
||||
s += texture(uSrc, vUv + vec2(o.x*2.,0.)).rgb; s += texture(uSrc, vUv + vec2(o.x,-o.y)).rgb*2.;
|
||||
s += texture(uSrc, vUv + vec2(0.,-o.y*2.)).rgb; s += texture(uSrc, vUv + vec2(-o.x,-o.y)).rgb*2.;
|
||||
frag = vec4(s/12. + texture(uBase, vUv).rgb*uMix, 1.);
|
||||
}`;
|
||||
SRC.comp_fs = `#version 300 es
|
||||
precision highp float; in vec2 vUv; out vec4 frag;
|
||||
uniform sampler2D uScene, uBloom; uniform vec2 uTexel; uniform float uExposure, uBloomK, uVig, uGrain, uT, uFade, uFxaa, uSat;
|
||||
uniform int uMode; uniform vec3 uLift; uniform vec3 uGain;
|
||||
${GLSL_COMMON}
|
||||
vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14),0.,1.); }
|
||||
float luma(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }
|
||||
vec3 ironbow(float t){
|
||||
t = clamp(t,0.,1.);
|
||||
vec3 a = vec3(0.0,0.0,0.05), b = vec3(0.32,0.0,0.55), c = vec3(0.86,0.12,0.25), d = vec3(1.0,0.6,0.0), e = vec3(1.0,1.0,0.85);
|
||||
if (t < 0.25) return mix(a,b,t/0.25); if (t < 0.5) return mix(b,c,(t-0.25)/0.25); if (t < 0.78) return mix(c,d,(t-0.5)/0.28); return mix(d,e,(t-0.78)/0.22);
|
||||
}
|
||||
vec4 sampleScene(vec2 uv){
|
||||
vec4 c = texture(uScene, uv);
|
||||
if (uFxaa > 0.5) {
|
||||
float lN = luma(texture(uScene, uv + vec2(0., uTexel.y)).rgb), lS = luma(texture(uScene, uv - vec2(0., uTexel.y)).rgb);
|
||||
float lE = luma(texture(uScene, uv + vec2(uTexel.x, 0.)).rgb), lW = luma(texture(uScene, uv - vec2(uTexel.x, 0.)).rgb);
|
||||
float lM = luma(c.rgb);
|
||||
float mn = min(lM, min(min(lN,lS), min(lE,lW))), mx = max(lM, max(max(lN,lS), max(lE,lW)));
|
||||
if (mx - mn > max(0.06, mx*0.15)) {
|
||||
vec2 dir = vec2(-((lN)-(lS)), (lE)-(lW));
|
||||
float rcp = 1./(min(abs(dir.x),abs(dir.y)) + 1e-3*(lN+lS+lE+lW) + 1e-4);
|
||||
dir = clamp(dir*rcp, -4., 4.) * uTexel;
|
||||
vec4 a = 0.5*(texture(uScene, uv + dir*(1./3.-0.5)) + texture(uScene, uv + dir*(2./3.-0.5)));
|
||||
c = vec4(a.rgb, c.a);
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
void main(){
|
||||
vec4 s = sampleScene(vUv);
|
||||
vec3 b = texture(uBloom, vUv).rgb;
|
||||
vec3 c = (s.rgb + b*uBloomK) * uExposure;
|
||||
float heat = s.a;
|
||||
vec2 cc = vUv - 0.5;
|
||||
float vig = 1. - dot(cc, cc) * uVig;
|
||||
if (uMode == 3) { // thermal
|
||||
float hv = heat*0.85 + luma(c)*0.12 + luma(b)*0.05;
|
||||
c = ironbow(hv) * 0.95;
|
||||
} else if (uMode == 4) { // NVG
|
||||
float l = luma(aces(c*2.2));
|
||||
l = pow(l, 0.8) + (hash12(vUv*vec2(1311.,977.) + fract(uT)*91.)-0.5)*0.12;
|
||||
l *= 0.92 + 0.08*sin(vUv.y*900.);
|
||||
c = vec3(0.25, 1.0, 0.35) * l * 1.1 + vec3(0.0,0.02,0.0);
|
||||
float circ = smoothstep(0.62, 0.48, length(cc*vec2(1.,1.)));
|
||||
c *= mix(0.15, 1., circ);
|
||||
} else {
|
||||
c = aces(c);
|
||||
float l = luma(c);
|
||||
c = mix(vec3(l), c, uSat);
|
||||
c = c*uGain + uLift*(1.-c);
|
||||
if (uMode == 2) c = mix(c, c*vec3(0.75,0.85,1.15), 0.5);
|
||||
}
|
||||
c *= vig;
|
||||
c = pow(max(c, 0.), vec3(1./2.2));
|
||||
c += (hash12(vUv*vec2(1717.,1313.) + fract(uT*0.37)*77.) - 0.5) * uGrain;
|
||||
c += (hash12(vUv*vec2(911.,733.)) - 0.5)/255.;
|
||||
frag = vec4(c * uFade, 1.);
|
||||
}`;
|
||||
SRC.trail_fs = `#version 300 es
|
||||
precision highp float; in vec4 vCol; in float vK; out vec4 frag;
|
||||
void main(){ float a = vCol.a; if (a < 0.002) discard; frag = vec4(vCol.rgb*a, a*0.3); }`;
|
||||
SRC.copy_fs = `#version 300 es
|
||||
precision highp float; in vec2 vUv; uniform sampler2D uSrc; out vec4 frag; void main(){ frag = texture(uSrc, vUv); }`;
|
||||
|
||||
/* ---------- GL helpers ---------- */
|
||||
GL.init = function (canvas) {
|
||||
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, preserveDrawingBuffer: false, powerPreference: 'high-performance', desynchronized: false });
|
||||
if (!gl) return null;
|
||||
GL.gl = gl;
|
||||
GL.hdr = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');
|
||||
GL.aniso = gl.getExtension('EXT_texture_filter_anisotropic');
|
||||
gl.getExtension('OES_texture_float_linear');
|
||||
GL.vao0 = gl.createVertexArray();
|
||||
GL.progs = {};
|
||||
const P = (name, vs, fs) => (GL.progs[name] = GL.program(vs, fs, name));
|
||||
P('globe', SRC.fsq_vs, SRC.globe_fs);
|
||||
P('sprite', SRC.sprite_vs, SRC.sprite_fs);
|
||||
P('flight', SRC.flight_vs, SRC.sprite_fs);
|
||||
P('sat', SRC.sat_vs, SRC.sprite_fs);
|
||||
P('trail', SRC.flight_vs, SRC.trail_fs);
|
||||
P('line', SRC.line_vs, SRC.line_fs);
|
||||
P('mesh', SRC.mesh_vs, SRC.mesh_fs);
|
||||
P('bright', SRC.fsq_vs, SRC.bright_fs);
|
||||
P('down', SRC.fsq_vs, SRC.down_fs);
|
||||
P('up', SRC.fsq_vs, SRC.up_fs);
|
||||
P('comp', SRC.fsq_vs, SRC.comp_fs);
|
||||
P('copy', SRC.fsq_vs, SRC.copy_fs);
|
||||
return gl;
|
||||
};
|
||||
GL.program = function (vsSrc, fsSrc, name) {
|
||||
const gl = GL.gl;
|
||||
const mk = (type, src) => {
|
||||
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(s);
|
||||
const lines = src.split('\n').map((l, i) => i + 1 + ': ' + l);
|
||||
const m = /0:(\d+)/.exec(log || ''); const ln = m ? +m[1] : 0;
|
||||
console.error(`[${name}] shader error`, log, '\n', lines.slice(Math.max(0, ln - 4), ln + 2).join('\n'));
|
||||
throw new Error('shader compile failed: ' + name);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const p = gl.createProgram();
|
||||
gl.attachShader(p, mk(gl.VERTEX_SHADER, vsSrc)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, fsSrc));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error(`[${name}] link`, gl.getProgramInfoLog(p)); throw new Error('link failed ' + name); }
|
||||
const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
|
||||
for (let i = 0; i < n; i++) { const inf = gl.getActiveUniform(p, i); u[inf.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(p, inf.name); }
|
||||
const a = {}, na = gl.getProgramParameter(p, gl.ACTIVE_ATTRIBUTES);
|
||||
for (let i = 0; i < na; i++) { const inf = gl.getActiveAttrib(p, i); a[inf.name] = gl.getAttribLocation(p, inf.name); }
|
||||
return { p, u, a, name };
|
||||
};
|
||||
GL.texture = function (img, { mips = true, wrap = 'repeat', aniso = 8 } = {}) {
|
||||
const gl = GL.gl, t = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap === 'repeat' ? gl.REPEAT : gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
if (mips) gl.generateMipmap(gl.TEXTURE_2D);
|
||||
if (GL.aniso && aniso) gl.texParameterf(gl.TEXTURE_2D, GL.aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(aniso, gl.getParameter(GL.aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
|
||||
return t;
|
||||
};
|
||||
GL.target = function (w, h, { hdr = true, depth = false, linear = true } = {}) {
|
||||
const gl = GL.gl;
|
||||
w = Math.max(1, w | 0); h = Math.max(1, h | 0);
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
const useHdr = hdr && GL.hdr;
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, useHdr ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, useHdr ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
const fb = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
||||
let rb = null;
|
||||
if (depth) {
|
||||
rb = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
|
||||
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, w, h);
|
||||
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb);
|
||||
}
|
||||
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
if (!ok && useHdr) { GL.hdr = false; return GL.target(w, h, { hdr: false, depth, linear }); }
|
||||
return { tex, fb, rb, w, h, dispose() { gl.deleteTexture(tex); gl.deleteFramebuffer(fb); if (rb) gl.deleteRenderbuffer(rb); } };
|
||||
};
|
||||
// chain of render targets for a given output size
|
||||
GL.chain = function (w, h) {
|
||||
const s = GL.target(w, h, { depth: true });
|
||||
const lv = [];
|
||||
let cw = Math.max(1, (w / 2) | 0), ch = Math.max(1, (h / 2) | 0);
|
||||
for (let i = 0; i < 4; i++) { lv.push({ a: GL.target(cw, ch), b: null }); cw = Math.max(1, (cw / 2) | 0); ch = Math.max(1, (ch / 2) | 0); }
|
||||
for (let i = 0; i < 3; i++) lv[i].b = GL.target(lv[i].a.w, lv[i].a.h);
|
||||
return { w, h, scene: s, lv, dispose() { s.dispose(); lv.forEach((l) => { l.a.dispose(); l.b && l.b.dispose(); }); } };
|
||||
};
|
||||
GL.fsq = function (prog) { const gl = GL.gl; gl.useProgram(prog.p); gl.bindVertexArray(GL.vao0); gl.drawArrays(gl.TRIANGLES, 0, 3); };
|
||||
GL.bindTex = function (prog, name, tex, unit) { const gl = GL.gl; gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(prog.u[name], unit); };
|
||||
GL.buffer = function (data, usage) { const gl = GL.gl, b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, usage || gl.STATIC_DRAW); return b; };
|
||||
// build a VAO from {name: [buffer, size, stride, offset, divisor]}
|
||||
GL.vao = function (prog, spec, index) {
|
||||
const gl = GL.gl, vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
for (const k in spec) {
|
||||
const loc = prog.a[k]; if (loc == null || loc < 0) continue;
|
||||
const [buf, size, stride, off, div] = spec[k];
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, off * 4);
|
||||
gl.vertexAttribDivisor(loc, div || 0);
|
||||
}
|
||||
if (index) gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);
|
||||
gl.bindVertexArray(null);
|
||||
return vao;
|
||||
};
|
||||
@@ -0,0 +1,578 @@
|
||||
/* World: time, sun, 44-layer catalog, GPU buffers, per-frame update, render, projection, picking. */
|
||||
const W = (GE.world = {});
|
||||
const FAM = {
|
||||
sky: { name: 'Sky', color: '#7CC4FF', icon: 'airplane' },
|
||||
sea: { name: 'Sea', color: '#38E1C6', icon: 'ferry' },
|
||||
orbit: { name: 'Orbit', color: '#C9A7FF', icon: 'satellite' },
|
||||
haz: { name: 'Hazards', color: '#FF6A3D', icon: 'flame' },
|
||||
eyes: { name: 'Eyes', color: '#FFD166', icon: 'eye' },
|
||||
ground: { name: 'Ground', color: '#9BE15D', icon: 'tram' },
|
||||
wx: { name: 'Weather', color: '#8AB4F8', icon: 'cloud' },
|
||||
infra: { name: 'Infra', color: '#B8C0CC', icon: 'bolt' },
|
||||
};
|
||||
GE.FAM = FAM;
|
||||
const hex2rgb = (h) => [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
|
||||
|
||||
/* ---------- layer catalog (44) ---------- */
|
||||
const L = (id, fam, name, on, icon) => ({ id, fam, name, on, icon, count: 0, status: 'live' });
|
||||
W.layers = [
|
||||
L('flights', 'sky', 'Commercial flights', true, 'airplane'), L('military', 'sky', 'Military aircraft', true, 'jet'), L('helis', 'sky', 'Helicopters', true, 'helicopter'),
|
||||
L('ga', 'sky', 'Private / GA', false, 'propeller'), L('squawk', 'sky', 'Emergency squawks', true, 'exclamation'), L('drones', 'sky', 'Drones (Remote ID)', true, 'drone'),
|
||||
L('airports', 'sky', 'Airport boards', false, 'board'), L('holding', 'sky', 'Holding patterns', true, 'loop'),
|
||||
L('ais', 'sea', 'AIS vessels', true, 'ferry'), L('ports', 'sea', 'Ports', false, 'anchor'), L('tankers', 'sea', 'Tankers / cargo', false, 'drop'),
|
||||
L('fishing', 'sea', 'Fishing effort', false, 'fish'), L('dark', 'sea', 'Dark vessels (AIS gaps)', false, 'eyeslash'), L('cables', 'sea', 'Submarine cables', false, 'cable'),
|
||||
L('sats', 'orbit', 'Satellites (TLE)', true, 'satellite'), L('crewed', 'orbit', 'ISS & crewed', true, 'iss'), L('trains', 'orbit', 'Starlink trains', true, 'train'),
|
||||
L('launches', 'orbit', 'Launches & trajectories', true, 'rocket'), L('debris', 'orbit', 'Debris', false, 'debris'), L('tracks', 'orbit', 'Ground tracks & passes', true, 'orbitpath'),
|
||||
L('quakes', 'haz', 'Earthquakes', true, 'wave'), L('fires', 'haz', 'Active fires (FIRMS)', true, 'flame'), L('perims', 'haz', 'Fire perimeters', false, 'perimeter'),
|
||||
L('floods', 'haz', 'Floods', true, 'drop'), L('cyclones', 'haz', 'Tropical cyclones', true, 'hurricane'), L('volcanoes', 'haz', 'Volcanoes', false, 'mountain'),
|
||||
L('aq', 'haz', 'Air quality', false, 'aqi'),
|
||||
L('cctv', 'eyes', 'Public CCTV', true, 'video'), L('signed', 'eyes', 'Signed captures', true, 'seal'), L('street', 'eyes', 'Street-level imagery', false, 'binoculars'),
|
||||
L('holo', 'eyes', 'Holograms', true, 'cube'), L('streams', 'eyes', 'Live streams', false, 'live'),
|
||||
L('transit', 'ground', 'Transit vehicles', false, 'tram'), L('traffic', 'ground', 'Traffic flow', true, 'car'), L('bikes', 'ground', 'Bike share', false, 'bike'),
|
||||
L('rail', 'ground', 'Rail', false, 'rail'), L('venues', 'ground', 'Events & venues', true, 'ticket'), L('alpr', 'ground', 'ALPR cameras', false, 'plate'),
|
||||
L('radar', 'wx', 'Radar', true, 'radar'), L('clouds', 'wx', 'Clouds', true, 'cloud'), L('lightning', 'wx', 'Lightning', true, 'bolt'),
|
||||
L('wind', 'wx', 'Wind', false, 'wind'), L('models', 'wx', 'Forecast models', false, 'spaghetti'),
|
||||
L('dc', 'infra', 'Datacenters', false, 'server'), L('dams', 'infra', 'Dams', false, 'dam'), L('power', 'infra', 'Power plants', false, 'bolt'),
|
||||
L('milbase', 'infra', 'Military installations', false, 'shield'), L('gnss', 'infra', 'GNSS interference', true, 'jam'), L('imagery', 'infra', 'Recent satellite imagery', false, 'frame'),
|
||||
];
|
||||
W.layerById = Object.fromEntries(W.layers.map((l) => [l.id, l]));
|
||||
W.on = (id) => !!W.layerById[id]?.on;
|
||||
|
||||
/* ---------- time ---------- */
|
||||
W.offsetH = 0; W.live = true; W.simMs = NOW; W.t = 0; W.sun = astro.sun(NOW);
|
||||
W.window = null;
|
||||
W.getWindow = () => W.window || { start: Date.now() - 72 * 3600000, end: Date.now() + 120 * 3600000 };
|
||||
W.setWindow = (w) => { W.window = w; bus.emit('window', w); };
|
||||
W.setTime = function (ms) {
|
||||
if (ms == null && !W.window) { W.live = true; W.offsetH = 0; }
|
||||
else { const win = W.getWindow(); if (ms == null) ms = (win.start + win.end) / 2; ms = clamp(ms, win.start, win.end); W.offsetH = (ms - Date.now()) / 3600000; W.live = !W.window && Math.abs(W.offsetH) < 1 / 120; }
|
||||
W.simMs = Date.now() + W.offsetH * 3600000;
|
||||
bus.emit('time', W.offsetH);
|
||||
};
|
||||
W.timeWindow = () => ({ ...W.getWindow(), now: Date.now() });
|
||||
W.userHolos = [];
|
||||
|
||||
/* ---------- static data for layers ---------- */
|
||||
const RS = rng(77);
|
||||
const near = (lat, lon, km, n, f) => { const out = []; for (let i = 0; i < n; i++) { const [a, b] = geo.dest(lat, lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f ? f(a, b, i) : [a, b]); } return out; };
|
||||
const BIG_CITIES = GE.CITY_V.slice(0, 32);
|
||||
const PORTS = [[51.95,4.05,'Rotterdam'],[1.26,103.84,'Singapore'],[31.36,121.6,'Shanghai'],[22.3,114.2,'Hong Kong'],[35.1,129.04,'Busan'],[25.0,55.06,'Jebel Ali'],[33.74,-118.26,'Los Angeles / Long Beach'],[53.54,9.97,'Hamburg'],[51.27,4.3,'Antwerp'],[29.6,32.55,'Suez'],[31.25,32.3,'Port Said'],[40.67,-74.05,'New York / NJ'],[-23.96,-46.3,'Santos'],[13.08,100.9,'Laem Chabang'],[22.6,120.27,'Kaohsiung'],[34.6,135.4,'Osaka'],[35.43,139.65,'Yokohama'],[-33.86,151.2,'Sydney'],[47.6,-122.35,'Seattle'],[30.6,122.08,'Ningbo'],[36.1,120.3,'Qingdao'],[9.35,-79.9,'Colón'],[8.95,-79.55,'Balboa'],[-33.9,18.43,'Cape Town'],[6.43,3.38,'Lagos'],[37.94,23.62,'Piraeus'],[44.4,8.9,'Genoa'],[43.3,5.35,'Marseille'],[29.75,-95.0,'Houston'],[19.0,72.85,'Mumbai (JNPT)']];
|
||||
const VOLCANOES = [[37.75,14.99,'Etna'],[19.42,-155.29,'Kīlauea'],[31.59,130.66,'Sakurajima'],[19.02,-98.62,'Popocatépetl'],[-7.54,110.44,'Merapi'],[14.47,-90.88,'Fuego'],[-77.53,167.15,'Erebus'],[63.63,-19.62,'Eyjafjallajökull'],[63.9,-22.27,'Reykjanes'],[-0.37,-78.44,'Cotopaxi'],[56.06,160.64,'Klyuchevskoy'],[1.68,127.88,'Ibu'],[-8.06,114.24,'Ijen'],[13.26,123.69,'Mayon'],[46.2,-122.19,'St Helens'],[40.82,14.43,'Vesuvius'],[-39.42,-71.93,'Villarrica'],[-1.52,29.25,'Nyiragongo'],[-15.79,-71.86,'Sabancaya'],[38.79,15.21,'Stromboli']];
|
||||
const DCS = [[39.04,-77.49,'Ashburn cluster'],[53.33,-6.4,'Dublin'],[50.11,8.68,'Frankfurt'],[1.3,103.8,'Singapore'],[52.37,4.9,'Amsterdam'],[45.6,-121.2,'The Dalles'],[33.45,-112.07,'Phoenix'],[41.6,-93.6,'Des Moines'],[35.69,139.69,'Tokyo'],[37.37,-121.92,'Santa Clara'],[59.33,18.06,'Stockholm'],[-23.5,-46.6,'São Paulo'],[19.07,72.88,'Mumbai'],[32.78,-96.8,'Dallas']];
|
||||
const DAMS = [[30.82,111.0,'Three Gorges'],[36.02,-114.74,'Hoover'],[-25.41,-54.59,'Itaipu'],[23.97,32.88,'Aswan High'],[47.96,-118.98,'Grand Coulee'],[7.76,-62.99,'Guri'],[34.08,72.7,'Tarbela'],[28.27,85.37,'Rasuwagadhi Hydro (approx.)'],[11.2,35.1,'Grand Ethiopian Renaissance'],[46.23,7.37,'Grande Dixence']];
|
||||
const MILB = [[31.33,-85.72,'Fort Novosel'],[36.95,-76.33,'Norfolk'],[34.68,-77.34,'Camp Lejeune'],[64.83,-147.6,'Eielson'],[13.58,144.92,'Andersen'],[26.27,127.75,'Kadena'],[49.44,7.6,'Ramstein'],[51.75,-1.58,'Brize Norton'],[37.0,35.42,'Incirlik'],[25.12,51.32,'Al Udeid'],[-7.31,72.41,'Diego Garcia'],[36.24,-115.03,'Nellis']];
|
||||
const PADS = [[28.5618,-80.5772,'SLC-40, Cape Canaveral'],[34.632,-120.611,'SLC-4E, Vandenberg'],[45.92,63.34,'Baikonur'],[5.24,-52.77,'Kourou'],[40.96,100.29,'Jiuquan'],[30.4,130.97,'Tanegashima'],[13.72,80.23,'Sriharikota'],[19.61,110.95,'Wenchang'],[25.997,-97.155,'Starbase']];
|
||||
const FISH = [[47,-50,6],[-12,-80,6],[18,-18,5],[40,150,7],[57,-170,5],[-45,-62,6],[64,-10,5],[10,-90,5],[-5,95,5],[20,115,5]];
|
||||
const GNSS_HOT = [[59.5,24.0,4],[55.5,20.5,3.5],[34.5,33.5,3],[44.5,33.5,3],[33.5,36.0,2.5],[27.0,56.5,2.5],[69.5,30.5,2],[46.5,31.5,2.5]];
|
||||
const CABLES = [
|
||||
[[50.8,-1.3],[50.0,-10],[47,-30],[43,-50],[40.5,-72]], [[51.5,-9.8],[52.5,-30],[48,-50],[44.5,-63.5]], [[43.3,5.3],[38,10],[34.5,25],[31.3,32.3],[29.9,32.5],[20,38.5],[12.6,43.6],[12,52],[16,62],[19,72.8]],
|
||||
[[35.4,139.6],[38,155],[42,180],[42,-150],[37,-125],[34.1,-118.4]], [[1.3,103.8],[5,112],[14,119],[22.3,114.2],[30,123],[35.1,129.0]], [[-33.9,151.2],[-25,170],[-10,-170],[10,-160],[21.3,-157.9],[34,-130],[37.7,-122.5]],
|
||||
[[-23.0,-43.2],[-15,-30],[0,-20],[15,-20],[33,-10],[38.7,-9.2]], [[51.5,-9.8],[53,-20],[60,-20],[64,-22]], [[1.3,103.8],[-5,100],[-20,105],[-32,115.8]], [[6.4,3.4],[0,0],[-10,8],[-25,12],[-34,18.4]],
|
||||
];
|
||||
const RAIL = [[[51.5,-0.13],[51.0,1.9],[50.85,4.35],[52.37,4.9]],[[48.85,2.35],[47.0,4.8],[45.75,4.85],[43.3,5.37]],[[52.52,13.4],[51.34,12.37],[50.11,8.68],[48.14,11.58]],[[35.68,139.77],[35.17,136.9],[34.69,135.5],[34.4,132.46],[33.59,130.4]],[[39.9,116.4],[36.65,117.0],[34.26,117.18],[32.06,118.8],[31.23,121.47]],[[40.75,-73.99],[39.95,-75.16],[39.29,-76.61],[38.9,-77.04]],[[40.42,-3.7],[41.39,2.17]],[[41.9,12.5],[43.77,11.25],[45.46,9.19]]];
|
||||
|
||||
function cityScatter(n, km, f) { const out = []; for (let i = 0; i < n; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [a, b] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * km); out.push(f(a, b, c, i)); } return out; }
|
||||
// holograms & signed captures (fictional authors). Some are placed on situations.
|
||||
const HOLOS = [
|
||||
{ lat: 28.2551, lon: 85.3712, by: 'tamang_lens', title: 'North bank, Timure — water at the road', t: -55 * 24 * 3600 + 0, sit: 'koshi' },
|
||||
{ lat: 28.1629, lon: 85.3402, by: 'hydro_nerd', title: 'Syaphrubesi bridge deck, 14:31 NPT', t: -55 * 86400, sit: 'koshi' },
|
||||
{ lat: 28.2802, lon: 85.3797, by: 'geodesy_dan', title: 'Rasuwagadhi — scour line photo', t: -54 * 86400, sit: 'koshi' },
|
||||
{ lat: 51.5560, lon: -0.2796, by: 'row_m_seat14', title: 'Encore from block 114', t: -3600 * 20, sit: 'concert' },
|
||||
{ lat: 40.7553, lon: -73.9838, by: 'chopper_cam9', title: 'Tower, 41st floor setback', t: -3600 * 9, sit: 'climb' },
|
||||
{ lat: 69.65, lon: 18.96, by: 'skywatch_ana', title: 'Corona overhead, Tromsø', t: -3600 * 18, sit: 'aurora' },
|
||||
{ lat: 29.93, lon: 32.56, by: 'ais_owl', title: 'Anchorage at dusk, 48 ships', t: -3600 * 7, sit: 'suez' },
|
||||
{ lat: 31.276, lon: -85.713, by: 'rotorwash_ret', title: 'Downwind leg, Cairns', t: -3600 * 30, sit: 'novosel' },
|
||||
];
|
||||
GE.HOLOS = HOLOS;
|
||||
|
||||
/* ---------- buffers ---------- */
|
||||
const SPR_STRIDE = 15; // pos3 col4 par4 ext4
|
||||
function pushSpr(a, v, col, size, glyph, hdg = -10, t0 = -1e9, tEnd = 1e9, grow = 0, seed = 0, heat = 0.5) {
|
||||
a.push(v[0], v[1], v[2], col[0], col[1], col[2], col[3] ?? 1, size, glyph, hdg, t0, tEnd, grow, seed, heat);
|
||||
}
|
||||
const LINE_STRIDE = 9; // pos3 col4 d2
|
||||
function pushPolyline(a, pts /* [v] */, col, dashed = 0) {
|
||||
let d = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const p0 = pts[i - 1], p1 = pts[i];
|
||||
const seg = v3.len(v3.sub(p1, p0));
|
||||
a.push(p0[0], p0[1], p0[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed);
|
||||
d += seg;
|
||||
a.push(p1[0], p1[1], p1[2], col[0], col[1], col[2], col[3] ?? 1, d, dashed);
|
||||
}
|
||||
}
|
||||
function gcPoints(lat1, lon1, lat2, lon2, n, altKm = 0, lift = 0) {
|
||||
const a = geo.vec(lat1, lon1), b = geo.vec(lat2, lon2), out = [];
|
||||
for (let i = 0; i <= n; i++) { const t = i / n; const p = geo.slerp(a, b, t); const r = 1 + (altKm + lift * Math.sin(Math.PI * t)) / EARTH_KM; out.push(v3.scale(p, r)); }
|
||||
return out;
|
||||
}
|
||||
function chainPoints(ll, altKm = 0, sub = 8) { const out = []; for (let i = 1; i < ll.length; i++) { const seg = gcPoints(ll[i - 1][0], ll[i - 1][1], ll[i][0], ll[i][1], sub, altKm); if (i > 1) seg.shift(); out.push(...seg); } return out; }
|
||||
function circlePts(lat, lon, km, n = 48, altKm = 0) { const out = []; for (let i = 0; i <= n; i++) { const [a, b] = geo.dest(lat, lon, (i / n) * TAU, km); out.push(geo.vec(a, b, altKm)); } return out; }
|
||||
|
||||
W.static = {}; // id → {vao, count, pass:'add'|'alpha', prog:'sprite'|'line'}
|
||||
function makeSpriteSet(id, arr, pass = 'add') {
|
||||
const gl = GL.gl, P = GL.progs.sprite, buf = GL.buffer(new Float32Array(arr));
|
||||
const S = SPR_STRIDE;
|
||||
const vao = GL.vao(P, { aPos: [buf, 3, S, 0], aCol: [buf, 4, S, 3], aPar: [buf, 4, S, 7], aExt: [buf, 4, S, 11] });
|
||||
return { vao, buf, count: arr.length / S, pass, kind: 'sprite' };
|
||||
}
|
||||
function makeLineSet(arr) {
|
||||
const P = GL.progs.line, buf = GL.buffer(new Float32Array(arr));
|
||||
const vao = GL.vao(P, { aPos: [buf, 3, LINE_STRIDE, 0], aCol: [buf, 4, LINE_STRIDE, 3], aD: [buf, 2, LINE_STRIDE, 7] });
|
||||
return { vao, buf, count: arr.length / LINE_STRIDE, kind: 'line' };
|
||||
}
|
||||
function addSet(layer, set) { (W.static[layer] = W.static[layer] || []).push(set); }
|
||||
|
||||
W.buildStatic = function () {
|
||||
const C = (fam, a = 1) => [...hex2rgb(FAM[fam].color), a];
|
||||
// quakes
|
||||
{ const a = []; for (const q of GE.QUAKES) pushSpr(a, geo.vec(q.lat, q.lon, 0.5), [1.0, 0.5, 0.3, 1], 10 + (q.mag - 2.5) * 9, 2, -10, q.t, 1e9, 0, RS(), 0.8); addSet('quakes', makeSpriteSet('quakes', a)); }
|
||||
// fires (+ smoke in alpha pass)
|
||||
{ const a = [], sm = []; for (const f of GE.FIRES) { pushSpr(a, geo.vec(f.lat, f.lon, 0.2), [1, 0.5, 0.15, 1], 4 + Math.sqrt(f.frp) * 0.9, 3, -10, f.t, 1e9, 0.4, RS(), 1.0); if (RS() < 0.35) pushSpr(sm, geo.vec(f.lat + 0.15, f.lon + 0.35, 2), [0.3, 0.28, 0.27, 0.32], 26 + RS() * 30, 11, -10, f.t + 3600, 1e9, 0.8, RS(), 0.5); }
|
||||
addSet('fires', makeSpriteSet('fires', a)); addSet('fires', makeSpriteSet('smoke', sm, 'alpha')); }
|
||||
// perimeters
|
||||
{ const a = []; for (const [la, lo, , , s] of GE.FIRE_CLUSTERS) pushPolyline(a, circlePts(la, lo, s * 111 * 0.9, 40, 0.3), [1, 0.42, 0.24, 0.6], 1); addSet('perims', makeLineSet(a)); }
|
||||
// floods
|
||||
{ const a = [], b = []; for (const [la, lo] of [[28.21, 85.35], [23.9, 90.6], [26.2, 91.7], [9.0, 7.5], [30.0, 70.9]]) { pushSpr(a, geo.vec(la, lo, 0.2), [0.4, 0.75, 1, 1], 16, 9, -10); pushSpr(b, geo.vec(la, lo, 0.5), [0.25, 0.55, 0.95, 0.35], 60, 11); } addSet('floods', makeSpriteSet('floods', a)); addSet('floods', makeSpriteSet('floods2', b, 'alpha')); }
|
||||
// volcanoes
|
||||
{ const a = []; for (const [la, lo] of VOLCANOES) pushSpr(a, geo.vec(la, lo, 0.5), [1, 0.45, 0.3, 1], 13, 14); addSet('volcanoes', makeSpriteSet('v', a)); }
|
||||
// air quality (alpha)
|
||||
{ const a = []; for (const [la, lo] of [[28.6, 77.2], [30.6, 75], [39.9, 116.4], [23.1, 113.3], [24.9, 67], [31.5, 74.3], [6.5, 3.4], [-6.2, 106.8], [40.2, -8.1], [-12, 25], [8, 18]]) for (let i = 0; i < 6; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 260); pushSpr(a, geo.vec(x, y, 1), [0.75, 0.5, 0.25, 0.22], 70 + RS() * 60, 11, -10, -1e9, 1e9, 0, RS()); } addSet('aq', makeSpriteSet('aq', a, 'alpha')); }
|
||||
// airports (boards)
|
||||
{ const a = []; for (const p of GE.AP) pushSpr(a, geo.vec(p.lat, p.lon, 0.3), C('sky', 0.9), 8 + Math.sqrt(p.w) * 1.4, 13); addSet('airports', makeSpriteSet('ap', a)); }
|
||||
// ports
|
||||
{ const a = []; for (const [la, lo] of PORTS) pushSpr(a, geo.vec(la, lo, 0.2), C('sea'), 13, 13); addSet('ports', makeSpriteSet('ports', a)); }
|
||||
// fishing effort (alpha)
|
||||
{ const a = []; for (const [la, lo, s] of FISH) for (let i = 0; i < 26; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.5, lo + RS.gauss() * s, 0.2), [0.2, 0.9, 0.8, 0.12], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); addSet('fishing', makeSpriteSet('fish', a, 'alpha')); }
|
||||
// dark vessels
|
||||
{ const a = []; for (let i = 0; i < 40; i++) { const h = RS.pick([[26.5, 56.4], [12.5, 43.5], [36.0, 15.0], [44, 33], [1.3, 104.2], [-12, -78], [22, 119]]); pushSpr(a, geo.vec(h[0] + RS.gauss() * 1.2, h[1] + RS.gauss() * 1.5, 0.2), [1, 0.4, 0.4, 0.95], 12, 16); } addSet('dark', makeSpriteSet('dark', a)); }
|
||||
// cables
|
||||
{ const a = []; for (const c of CABLES) pushPolyline(a, chainPoints(c, -0.5, 10), [0.3, 0.9, 0.85, 0.35]); addSet('cables', makeLineSet(a)); }
|
||||
// launches: pads + trajectory
|
||||
{ const a = []; for (const [la, lo] of PADS) pushSpr(a, geo.vec(la, lo, 0.3), C('orbit'), 12, 9, -10, -1e9, 1e9, 0, RS()); addSet('launches', makeSpriteSet('pads', a));
|
||||
const l = []; const tr = []; for (let i = 0; i <= 40; i++) { const f = i / 40; const [la, lo] = geo.dest(28.5618, -80.5772, 52 * D2R, f * 1900); tr.push(geo.vec(la, lo, Math.pow(f, 0.55) * 210)); } pushPolyline(l, tr, [0.85, 0.7, 1, 0.9], 1); addSet('launches', makeLineSet(l)); }
|
||||
// cyclone track (past solid, forecast dashed) + forecast cone + model spaghetti
|
||||
{ const a = []; const tr = GE.CYCLONE.track; const past = tr.filter((x) => x[0] <= 0).map((x) => [x[1], x[2]]); const fut = tr.filter((x) => x[0] >= 0).map((x) => [x[1], x[2]]);
|
||||
pushPolyline(a, chainPoints(past, 1, 6), [1, 0.6, 0.45, 0.95]); pushPolyline(a, chainPoints(fut, 1, 6), [1, 0.75, 0.6, 0.95], 1);
|
||||
// cone edges
|
||||
const left = [], right = []; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h), c2 = GE.CYCLONE.at(h + 3); const br = geo.bearing(c.lat, c.lon, c2.lat, c2.lon); const r = GE.CYCLONE.cone(h); left.push(geo.vec(...geo.dest(c.lat, c.lon, br - Math.PI / 2, r), 1)); right.push(geo.vec(...geo.dest(c.lat, c.lon, br + Math.PI / 2, r), 1)); }
|
||||
pushPolyline(a, left, [1, 0.75, 0.6, 0.5]); pushPolyline(a, right, [1, 0.75, 0.6, 0.5]);
|
||||
const end = GE.CYCLONE.at(120); pushPolyline(a, circlePts(end.lat, end.lon, GE.CYCLONE.cone(120), 40, 1), [1, 0.75, 0.6, 0.35]);
|
||||
addSet('cyclones', makeLineSet(a));
|
||||
// cone fill as soft sprites
|
||||
const f = []; for (let h = 3; h <= 120; h += 3) { const c = GE.CYCLONE.at(h); pushSpr(f, geo.vec(c.lat, c.lon, 1), [1, 0.55, 0.4, 0.07], 1, 11, -10, -1e9, 1e9, 0, h); f[f.length - 8] = GE.CYCLONE.cone(h); }
|
||||
W.coneSprites = f; // sized per-frame (km → px)
|
||||
const m = []; for (let k = 0; k < 14; k++) { const pts = []; let la = GE.CYCLONE.at(0).lat, lo = GE.CYCLONE.at(0).lon; for (let h = 0; h <= 120; h += 6) { const c = GE.CYCLONE.at(h); const sp = h / 120; pts.push([c.lat + Math.sin(k * 1.7) * sp * 4.5 + RS.gauss() * sp, c.lon + Math.cos(k * 2.3) * sp * 6 + RS.gauss() * sp]); } pushPolyline(m, chainPoints(pts, 1, 4), [0.6, 0.75, 1, 0.45]); }
|
||||
addSet('models', makeLineSet(m)); }
|
||||
// eyes
|
||||
{ const a = cityScatter(320, 18, (la, lo) => la); const s = []; for (let i = 0; i < 320; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 16); pushSpr(s, geo.vec(x, y, 0.05), C('eyes'), 13, 5); }
|
||||
// Midtown / Wembley / Chicago extra cameras for close-ups
|
||||
for (const [la, lo, n] of [[40.7549, -73.984, 40], [51.556, -0.2796, 18], [41.8789, -87.6359, 30], [28.163, 85.34, 4]]) for (let i = 0; i < n; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, 0.15 + RS() * 1.6); pushSpr(s, geo.vec(x, y, 0.02), C('eyes'), 15, 5); }
|
||||
addSet('cctv', makeSpriteSet('cctv', s)); }
|
||||
{ const s = []; for (let i = 0; i < 70; i++) { const c = BIG_CITIES[(i * 7) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 30); pushSpr(s, geo.vec(x, y, 0.05), [1, 0.82, 0.4, 1], 12, 6, -10, -1e9, 1e9, 0, RS(), 0.4); } for (const hh of HOLOS) pushSpr(s, geo.vec(hh.lat, hh.lon, 0.05), [1, 0.82, 0.4, 1], 15, 6); addSet('signed', makeSpriteSet('signed', s)); }
|
||||
{ const s = cityScatter(1800, 22, () => 0); const a = []; for (let i = 0; i < 1800; i++) { const c = BIG_CITIES[i % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 20); pushSpr(a, geo.vec(x, y, 0.02), [1, 0.9, 0.6, 0.45], 3.2, 0); } addSet('street', makeSpriteSet('street', a)); }
|
||||
{ const a = [], l = []; for (const hh of HOLOS) { pushSpr(a, geo.vec(hh.lat, hh.lon, 0.35), [1, 0.85, 0.45, 1], 22, 6, -10, -1e9, 1e9, 0, RS(), 0.4); pushPolyline(l, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.35)], [1, 0.85, 0.5, 0.9]); } addSet('holo', makeSpriteSet('holo', a)); addSet('holo', makeLineSet(l)); }
|
||||
{ const a = []; for (let i = 0; i < 36; i++) { const c = BIG_CITIES[(i * 5) % BIG_CITIES.length]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, RS() * 25); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.35, 0.4, 1], 12, 9, -10, -1e9, 1e9, 0, RS()); } addSet('streams', makeSpriteSet('streams', a)); }
|
||||
// ground
|
||||
{ const a = []; for (let i = 0; i < 1500; i++) { const c = BIG_CITIES[i % 20]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 14); pushSpr(a, geo.vec(x, y, 0.02), C('ground', 0.9), 3.5, 0); } addSet('bikes', makeSpriteSet('bikes', a)); }
|
||||
{ const a = []; for (const r of RAIL) pushPolyline(a, chainPoints(r, 0.1, 6), [0.62, 0.88, 0.37, 0.65]); addSet('rail', makeLineSet(a)); }
|
||||
{ const a = []; for (const [la, lo] of [[51.556, -0.2796], [40.7505, -73.9934], [41.8623, -87.6167], [34.0141, -118.2879], [48.8414, 2.253], [40.4531, -3.6883], [35.6681, 139.7], [-33.891, 151.2249], [53.4631, -2.2913], [41.3809, 2.1228]]) pushSpr(a, geo.vec(la, lo, 0.1), C('ground'), 14, 9, -10, -1e9, 1e9, 0, RS()); addSet('venues', makeSpriteSet('venues', a)); }
|
||||
{ const a = []; for (let i = 0; i < 300; i++) { const c = BIG_CITIES[[0, 15, 16, 26, 24][i % 5]]; const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, Math.sqrt(RS()) * 30); pushSpr(a, geo.vec(x, y, 0.02), [0.75, 0.9, 0.6, 0.85], 7, 13); } addSet('alpr', makeSpriteSet('alpr', a)); }
|
||||
// weather: radar blobs + lightning
|
||||
{ const a = [], b = []; const cy = GE.CYCLONE.at(0);
|
||||
for (let i = 0; i < 70; i++) { const ang = RS() * TAU, rr = Math.pow(RS(), 0.7) * 480; const [x, y] = geo.dest(cy.lat, cy.lon, ang, rr); pushSpr(a, geo.vec(x, y, 1), rr < 150 ? [1, 0.75, 0.2, 0.16] : [0.3, 0.85, 0.4, 0.11], 30 + RS() * 40, 11, -10, -1e9, 1e9, 0, RS()); if (RS() < 0.5) pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 18, 12, -10, -1e9, 1e9, 0, RS(), 0.9); }
|
||||
for (const [la, lo] of [[45, -95], [5, 20], [-5, -60], [10, 100], [32, -85]]) for (let i = 0; i < 18; i++) { const [x, y] = geo.dest(la, lo, RS() * TAU, RS() * 500); pushSpr(a, geo.vec(x, y, 1), [0.3, 0.85, 0.4, 0.1], 24 + RS() * 30, 11, -10, -1e9, 1e9, 0, RS()); pushSpr(b, geo.vec(x, y, 2), [0.85, 0.85, 1, 1], 14, 12, -10, -1e9, 1e9, 0, RS(), 0.9); }
|
||||
addSet('radar', makeSpriteSet('radar', a, 'alpha')); addSet('lightning', makeSpriteSet('lightning', b)); }
|
||||
// infra
|
||||
{ const a = []; for (const [la, lo] of DCS) pushSpr(a, geo.vec(la, lo, 0.1), C('infra'), 13, 13); addSet('dc', makeSpriteSet('dc', a)); }
|
||||
{ const a = []; for (const [la, lo] of DAMS) pushSpr(a, geo.vec(la, lo, 0.1), [0.6, 0.8, 1, 1], 13, 13); addSet('dams', makeSpriteSet('dams', a)); }
|
||||
{ const a = []; for (let i = 0; i < 260; i++) { const c = RS.pick(GE.AP); const [x, y] = geo.dest(c.lat, c.lon, RS() * TAU, 20 + RS() * 120); pushSpr(a, geo.vec(x, y, 0.1), [1, 0.85, 0.5, 0.8], 6, 0); } addSet('power', makeSpriteSet('power', a)); }
|
||||
{ const a = []; for (const [la, lo] of MILB) pushSpr(a, geo.vec(la, lo, 0.1), [1, 0.6, 0.4, 0.9], 13, 13); addSet('milbase', makeSpriteSet('mb', a)); }
|
||||
{ const a = []; for (const [la, lo, s] of GNSS_HOT) for (let i = 0; i < 9; i++) pushSpr(a, geo.vec(la + RS.gauss() * s * 0.25, lo + RS.gauss() * s * 0.45, 1), [1, 0.32, 0.36, 0.22], 18 + s * 4, 10, -10, -1e9, 1e9, 0, RS(), 0.6); addSet('gnss', makeSpriteSet('gnss', a, 'alpha')); }
|
||||
{ const a = []; for (let i = 0; i < 40; i++) { const c = RS.pick(GE.AP); const la = c.lat + RS.gauss() * 2, lo = c.lon + RS.gauss() * 2, w = 0.5 + RS() * 0.6; const q = [[la - w, lo - w], [la - w, lo + w], [la + w, lo + w], [la + w, lo - w], [la - w, lo - w]].map(([x, y]) => geo.vec(x, y, 0.5)); pushPolyline(a, q, [0.8, 0.85, 0.95, 0.5]); } addSet('imagery', makeLineSet(a)); }
|
||||
// AIS lanes as faint context lines
|
||||
{ const a = []; for (const l of GE.LANES) pushPolyline(a, chainPoints(l, 0, 6), [0.22, 0.88, 0.78, 0.07]); addSet('ais', makeLineSet(a)); }
|
||||
// situation pins handled dynamically
|
||||
W.counts.quakes = GE.QUAKES.length; W.counts.ports = PORTS.length; W.counts.volcanoes = VOLCANOES.length; W.counts.dc = DCS.length; W.counts.dams = DAMS.length; W.counts.milbase = MILB.length; W.counts.cables = CABLES.length;
|
||||
W.counts.launches = PADS.length; W.counts.cctv = 408; W.counts.signed = 78; W.counts.street = 1800; W.counts.holo = HOLOS.length; W.counts.streams = 36; W.counts.bikes = 1500; W.counts.rail = 412; W.counts.venues = 10; W.counts.alpr = 300;
|
||||
W.counts.radar = 160; W.counts.clouds = 1; W.counts.models = 14; W.counts.power = 260; W.counts.gnss = 8; W.counts.imagery = 40; W.counts.fishing = 260; W.counts.dark = 40; W.counts.aq = 66; W.counts.floods = 5; W.counts.perims = GE.FIRE_CLUSTERS.length;
|
||||
};
|
||||
W.counts = {};
|
||||
|
||||
/* ---------- GPU flights & satellites ---------- */
|
||||
W.buildMovers = function () {
|
||||
const gl = GL.gl;
|
||||
const F = GE.FLIGHTS, n = F.length, a = new Float32Array(n * 14);
|
||||
F.forEach((f, i) => { const o = i * 14; a.set(f.a.v, o); a.set(f.b.v, o + 3); a[o + 6] = f.t0; a[o + 7] = f.dur; a[o + 8] = f.period; a[o + 9] = f.turn; a[o + 10] = f.alt; a[o + 11] = f.kind; a[o + 12] = hash01(i); a[o + 13] = f.dKm; });
|
||||
const fb = GL.buffer(a);
|
||||
const spec = (P) => ({ aA: [fb, 3, 14, 0, 1], aB: [fb, 3, 14, 3, 1], aTime: [fb, 4, 14, 6, 1], aMeta: [fb, 4, 14, 10, 1] });
|
||||
W.flightVao = GL.vao(GL.progs.flight, spec());
|
||||
const K = 28, kb = GL.buffer(new Float32Array(Array.from({ length: K }, (_, i) => i / (K - 1))));
|
||||
const ts = spec(); ts.aK = [kb, 1, 1, 0, 0];
|
||||
W.trailVao = GL.vao(GL.progs.trail, ts); W.trailK = K; W.nFlights = n;
|
||||
const S = GE.SATS, sa = new Float32Array(S.length * 8);
|
||||
S.forEach((s, i) => { sa.set([s.inc, s.raan, s.u0, s.n, s.alt, s.kind, s.geo ? 1 : 0, hash01(i + 7)], i * 8); });
|
||||
const sb = GL.buffer(sa);
|
||||
W.satVao = GL.vao(GL.progs.sat, { aEl: [sb, 4, 8, 0], aSM: [sb, 4, 8, 4] }); W.nSats = S.length;
|
||||
// dynamic sprites / lines
|
||||
W.dynBuf = gl.createBuffer(); W.dynVao = null; W.dynCount = 0; W.dynCap = 0;
|
||||
W.dynLineBuf = gl.createBuffer(); W.dynLineCount = 0; W.dynLineCap = 0;
|
||||
W.alphaBuf = gl.createBuffer(); W.alphaCount = 0; W.alphaCap = 0;
|
||||
};
|
||||
function uploadDyn(bufKey, vaoKey, capKey, countKey, arr, kind) {
|
||||
const gl = GL.gl; const f = new Float32Array(arr);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, W[bufKey]);
|
||||
if (f.length > W[capKey]) { W[capKey] = Math.max(f.length, W[capKey] * 1.5) | 0; gl.bufferData(gl.ARRAY_BUFFER, W[capKey] * 4, gl.DYNAMIC_DRAW);
|
||||
if (kind === 'line') W[vaoKey] = GL.vao(GL.progs.line, { aPos: [W[bufKey], 3, LINE_STRIDE, 0], aCol: [W[bufKey], 4, LINE_STRIDE, 3], aD: [W[bufKey], 2, LINE_STRIDE, 7] });
|
||||
else W[vaoKey] = GL.vao(GL.progs.sprite, { aPos: [W[bufKey], 3, SPR_STRIDE, 0], aCol: [W[bufKey], 4, SPR_STRIDE, 3], aPar: [W[bufKey], 4, SPR_STRIDE, 7], aExt: [W[bufKey], 4, SPR_STRIDE, 11] }); }
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, f);
|
||||
W[countKey] = f.length / (kind === 'line' ? LINE_STRIDE : SPR_STRIDE);
|
||||
}
|
||||
|
||||
/* ---------- CPU movers: helicopters, drones, holding, transit, wind, runners ---------- */
|
||||
const HELI_CIRCUITS = [];
|
||||
(function () {
|
||||
// Fort Novosel: Cairns AAF + stagefields — rectangular circuits (illustrative)
|
||||
const fields = [[31.276, -85.713, 'Cairns'], [31.36, -85.63, 'Stagefield A'], [31.20, -85.81, 'Stagefield B'], [31.42, -85.79, 'Stagefield C']];
|
||||
let k = 0;
|
||||
for (const [la, lo, nm] of fields) for (let i = 0; i < (nm === 'Cairns' ? 7 : 4); i++) HELI_CIRCUITS.push({ lat: la, lon: lo, w: 1.6 + RS() * 0.6, h: 0.9 + RS() * 0.3, rot: (nm === 'Cairns' ? 0.06 : RS() * 0.6), alt: 0.15 + RS() * 0.04, period: 330 + RS() * 60, ph: i / 7 + RS() * 0.05, cs: ['HAWK', 'LAKOTA', 'ROTOR', 'TALON'][k % 4] + ' ' + (21 + k), field: nm, type: k % 3 ? 'UH-72' : 'TH-73', idx: k++ });
|
||||
// news helicopters over the tower (orbit), and city police/EMS
|
||||
for (let i = 0; i < 3; i++) HELI_CIRCUITS.push({ orbit: true, lat: 40.7553, lon: -73.9838, r: 0.55 + i * 0.25, alt: 0.33 + i * 0.06, period: 140 + i * 30, ph: i * 0.33, cs: ['N9 NEWS', 'SKY 4', 'EYE 2'][i] + ' (illustrative)', type: 'AS350', idx: k++ });
|
||||
for (let i = 0; i < 18; i++) { const c = BIG_CITIES[i]; HELI_CIRCUITS.push({ orbit: true, lat: c.lat + RS.gauss() * 0.05, lon: c.lon + RS.gauss() * 0.05, r: 1 + RS() * 3, alt: 0.3 + RS() * 0.3, period: 200 + RS() * 300, ph: RS(), cs: 'MED ' + (100 + i), type: 'EC135', idx: k++ }); }
|
||||
})();
|
||||
GE.HELI_CIRCUITS = HELI_CIRCUITS;
|
||||
function heliAt(hc, t) {
|
||||
const ph = ((t / hc.period + hc.ph) % 1 + 1) % 1;
|
||||
if (hc.orbit) { const ang = ph * TAU; const [la, lo] = geo.dest(hc.lat, hc.lon, ang, hc.r); return { lat: la, lon: lo, altKm: hc.alt, hdg: ang + Math.PI / 2, v: geo.vec(la, lo, hc.alt), spd: (TAU * hc.r * 1000) / hc.period * 1.944 }; }
|
||||
// rectangle circuit: perimeter param
|
||||
const W2 = hc.w, H2 = hc.h, P = 2 * (W2 + H2); let d = ph * P; let x, y, hd;
|
||||
if (d < W2) { x = d - W2 / 2; y = -H2 / 2; hd = Math.PI / 2; } else if ((d -= W2) < H2) { x = W2 / 2; y = d - H2 / 2; hd = 0; } else if ((d -= H2) < W2) { x = W2 / 2 - d; y = H2 / 2; hd = -Math.PI / 2; } else { d -= W2; x = -W2 / 2; y = H2 / 2 - d; hd = Math.PI; }
|
||||
// round corners a bit
|
||||
const c = Math.cos(hc.rot), s = Math.sin(hc.rot); const ex = x * c - y * s, ny = x * s + y * c;
|
||||
const [la, lo] = geo.offset(hc.lat, hc.lon, ex * 1000, ny * 1000);
|
||||
// altitude: descend on final (last quarter), climb after takeoff
|
||||
const alt = hc.alt * (0.35 + 0.65 * smooth(0.0, 0.12, ph) * smooth(1.0, 0.86, ph));
|
||||
return { lat: la, lon: lo, altKm: alt, hdg: hd + hc.rot * -1, v: geo.vec(la, lo, alt), spd: (P * 1000 / hc.period) * 1.944 };
|
||||
}
|
||||
GE.heliAt = heliAt;
|
||||
const HOLD_STACKS = [[51.72, -0.55, 'BNN'], [51.30, -0.45, 'OCK'], [51.33, 0.03, 'BIG'], [51.65, 0.15, 'LAM'], [40.9, -73.2, 'CCC'], [33.9, -84.0, 'ATL-E']];
|
||||
const HOLDERS = []; HOLD_STACKS.forEach(([la, lo, nm], si) => { for (let i = 0; i < (si < 4 ? 5 : 3); i++) HOLDERS.push({ la, lo, nm, lvl: 7 + i * 1, ph: RS(), cs: GE.FLIGHTS[(si * 13 + i * 7) % 3000].cs }); });
|
||||
function holdAt(hd, t) {
|
||||
const per = 240, ph = ((t / per + hd.ph) % 1 + 1) % 1; // racetrack 4 min
|
||||
const L2 = 7, Rr = 2.2; const P = 2 * L2 + TAU * Rr; let d = ph * P; let x, y, hh;
|
||||
if (d < L2) { x = d - L2 / 2; y = Rr; hh = Math.PI / 2; } else if ((d -= L2) < Math.PI * Rr) { const a = d / Rr; x = L2 / 2 + Math.sin(a) * Rr; y = Math.cos(a) * Rr; hh = Math.PI / 2 + a; } else if ((d -= Math.PI * Rr) < L2) { x = L2 / 2 - d; y = -Rr; hh = -Math.PI / 2; } else { d -= L2; const a = d / Rr; x = -L2 / 2 - Math.sin(a) * Rr; y = -Math.cos(a) * Rr; hh = -Math.PI / 2 + a; }
|
||||
const [la, lo] = geo.offset(hd.la, hd.lo, x * 1000, y * 1000);
|
||||
return { lat: la, lon: lo, altKm: hd.lvl * 0.3048, hdg: hh, v: geo.vec(la, lo, hd.lvl * 0.3048) };
|
||||
}
|
||||
// delivery-drone corridor (illustrative, suburban Texas)
|
||||
const DRONE_DEPOT = [30.5880, -96.2900];
|
||||
const DRONES = []; for (let i = 0; i < 16; i++) { const brg = RS() * TAU, km = 1.2 + RS() * 4.5; DRONES.push({ to: geo.dest(DRONE_DEPOT[0], DRONE_DEPOT[1], brg, km), km, period: 600 + RS() * 400, ph: RS(), id: 'RID-' + (4400 + i) }); }
|
||||
GE.DRONES = DRONES; GE.DRONE_DEPOT = DRONE_DEPOT;
|
||||
function droneAt(d, t) {
|
||||
const ph = ((t / d.period + d.ph) % 1 + 1) % 1; const out = ph < 0.5; const f = out ? ph * 2 : (1 - ph) * 2;
|
||||
const s = smooth(0, 1, f); const la = lerp(DRONE_DEPOT[0], d.to[0], s), lo = lerp(DRONE_DEPOT[1], d.to[1], s);
|
||||
const alt = 0.06 * Math.min(1, Math.min(f, 1 - f) * 12 + 0.05);
|
||||
return { lat: la, lon: lo, altKm: alt, v: geo.vec(la, lo, alt), hdg: geo.bearing(DRONE_DEPOT[0], DRONE_DEPOT[1], d.to[0], d.to[1]) + (out ? 0 : Math.PI) };
|
||||
}
|
||||
// marathon (fictional race) — runners on a lakeshore loop in Chicago
|
||||
const RUN_ROUTE = [[41.8827, -87.6233], [41.8930, -87.6240], [41.9100, -87.6330], [41.9280, -87.6380], [41.9400, -87.6440], [41.9280, -87.6420], [41.9100, -87.6300], [41.8960, -87.6180], [41.8800, -87.6150], [41.8670, -87.6170], [41.8520, -87.6200], [41.8380, -87.6080], [41.8520, -87.6150], [41.8680, -87.6230], [41.8827, -87.6233]];
|
||||
const RUN_V = chainPoints(RUN_ROUTE, 0.002, 6); const RUN_CUM = [0]; for (let i = 1; i < RUN_V.length; i++) RUN_CUM.push(RUN_CUM[i - 1] + v3.len(v3.sub(RUN_V[i], RUN_V[i - 1])));
|
||||
const RUNNERS = []; for (let i = 0; i < 260; i++) RUNNERS.push({ pace: 0.9 + Math.pow(RS(), 1.6) * 1.1, off: RS() * 0.18, lane: RS.gauss() * 0.4, bib: 1000 + i });
|
||||
GE.RUN_ROUTE = RUN_ROUTE;
|
||||
function runnerAt(r, t) {
|
||||
const L = RUN_CUM[RUN_CUM.length - 1]; let d = ((r.off + (t / 3600) * 3.2 / r.pace / (L * EARTH_KM / 1)) % 1 + 1) % 1 * L;
|
||||
let i = 1; while (i < RUN_CUM.length - 1 && RUN_CUM[i] < d) i++;
|
||||
const f = (d - RUN_CUM[i - 1]) / ((RUN_CUM[i] - RUN_CUM[i - 1]) || 1); const v = v3.lerp(RUN_V[i - 1], RUN_V[i], f); return v;
|
||||
}
|
||||
// wind particles (zonal jets + cyclone swirl)
|
||||
const WIND = []; for (let i = 0; i < 1400; i++) WIND.push({ lat: Math.asin(RS() * 2 - 1) * R2D * 0.95, lon: RS() * 360 - 180, ph: RS(), life: 5400 + RS() * 5400 });
|
||||
function windVel(lat, lon, t) { // deg/hour
|
||||
const jet = 34 * Math.exp(-Math.pow((Math.abs(lat) - 45) / 10, 2)) - 8 * Math.exp(-Math.pow(lat / 14, 2)) + 6 * Math.sin(lon * D2R * 3 + t / 40000);
|
||||
let u = jet * 3.6 / 111 / Math.max(0.2, Math.cos(lat * D2R)), v = 4 * Math.sin(lon * D2R * 2 + lat * D2R * 3) * 3.6 / 111;
|
||||
const c = GE.CYCLONE.at(W.offsetH); const dlat = lat - c.lat, dlon = (lon - c.lon) * Math.cos(lat * D2R); const r = Math.hypot(dlat, dlon);
|
||||
if (r < 12) { const s = 60 * Math.exp(-r / 3.5) * (1 - Math.exp(-r * r / 0.5)); u += (-dlat / (r + 1e-3)) * s * 3.6 / 111; v += (dlon / (r + 1e-3)) * s * 3.6 / 111; }
|
||||
return [u, v];
|
||||
}
|
||||
|
||||
W.squawks = [41, 1207].map((i) => i % GE.FLIGHTS.length);
|
||||
W.situationPins = []; // filled by situations module
|
||||
|
||||
W.updateDynamic = function () {
|
||||
const t = W.t, a = [], al = [], ln = [];
|
||||
// ships (AIS) — color by kind when tankers layer on
|
||||
const showAis = W.on('ais'), showTk = W.on('tankers');
|
||||
if (showAis || showTk) {
|
||||
if (!W._shipCache || Math.abs(W._shipCacheT - t) > 6 || W._shipCacheN !== GE.SHIPS.length) { W._shipCache = GE.SHIPS.map((s) => shipAt(s, t)); W._shipCacheT = t; W._shipCacheN = GE.SHIPS.length; }
|
||||
GE.SHIPS.forEach((s, i) => {
|
||||
const p = W._shipCache[i]; const tank = s.kind === 'tanker' || s.kind === 'LNG';
|
||||
if (!showAis && !tank) return;
|
||||
const ak = clamp(lerp(0.32, 0.95, smooth(1.2, 0.08, W.lastRange ?? 1)), 0.3, 1);
|
||||
const col = showTk && tank ? [1, 0.7, 0.35, ak] : s.queue ? [0.5, 1, 0.9, ak] : [0.22, 0.88, 0.78, ak * 0.95];
|
||||
pushSpr(a, geo.vec(p.lat, p.lon, 0.03), col, (showTk && tank ? 10 : 8.5), 4, p.hdg, -1e9, 1e9, 0, i, 0.35);
|
||||
});
|
||||
}
|
||||
if (W.on('helis')) for (const hc of HELI_CIRCUITS) { const p = heliAt(hc, t); pushSpr(a, p.v, [0.62, 1, 0.6, 1], 17, 8, p.hdg, -1e9, 1e9, 0, hc.idx, 0.7); }
|
||||
if (W.on('holding')) for (const hd of HOLDERS) { const p = holdAt(hd, t); pushSpr(a, p.v, [0.56, 0.82, 1, 1], 13, 1, p.hdg, -1e9, 1e9, 0, 0, 0.55); }
|
||||
if (W.on('drones')) for (const d of DRONES) { const p = droneAt(d, t); pushSpr(a, p.v, [0.75, 1, 0.95, 1], 13, 17, p.hdg, -1e9, 1e9, 0, 0, 0.5); }
|
||||
if (W.on('squawk')) for (const i of W.squawks) { const p = flightAt(GE.FLIGHTS[i], t); if (p) pushSpr(a, v3.scale(p.v, 1 + p.altKm * W.altX / EARTH_KM), [1, 0.25, 0.25, 1], 30, 9, -10, -1e9, 1e9, 0, i * 0.37, 0.8); }
|
||||
if (W.on('venues') || W.on('transit')) for (const r of RUNNERS) pushSpr(a, runnerAt(r, t), [0.62, 0.9, 0.37, 1], 6, 16, -10, -1e9, 1e9, 0, 0, 0.6);
|
||||
if (W.on('transit')) for (let i = 0; i < 400; i++) { const c = BIG_CITIES[i % 16]; const ang = hash01(i) * TAU + t / (600 + hash01(i + 3) * 900); const rr = 2 + hash01(i + 9) * 10; const [la, lo] = geo.dest(c.lat, c.lon, ang, rr); pushSpr(a, geo.vec(la, lo, 0.01), [0.62, 0.9, 0.37, 0.95], 5, 0); }
|
||||
if (W.on('wind')) for (const w of WIND) { const age = ((t / w.life + w.ph) % 1 + 1) % 1; let la = w.lat, lo = w.lon; const steps = 4, dtH = (age * w.life) / 3600 / steps; for (let k = 0; k < steps; k++) { const [u, v] = windVel(la, lo, t); la += v * dtH; lo += u * dtH; } const [u, v] = windVel(la, lo, t); const fade = Math.sin(age * Math.PI); pushSpr(a, geo.vec(la, lo, 3), [0.75, 0.85, 1, 0.55 * fade], 14, 15, Math.atan2(u * Math.cos(la * D2R), v), -1e9, 1e9, 0, 0, 0.1); }
|
||||
for (const hh of W.userHolos) pushSpr(a, geo.vec(hh.lat, hh.lon, 0.06), [1, 0.85, 0.45, 1], 24, 6, -10, -1e9, 1e9, 0, 0.2, 0.4);
|
||||
if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - (Date.now() - 10.5 * 3600000)) / (1.9 * 3600000), 0, 1); const [la, lo] = geo.offset(40.7553, -73.9838, 16, 23.5); pushSpr(a, geo.vec(la, lo, (12 + f * 372) / 1000), [1, 0.3, 0.3, 1], 16, 9, -10, -1e9, 1e9, 0, 0.7, 0.9); }
|
||||
// situation pins
|
||||
for (const p of W.situationPins) pushSpr(a, geo.vec(p.lat, p.lon, 0.2), p.col, p.size || 16, 9, -10, -1e9, 1e9, 0, p.seed || 0, 0.6);
|
||||
// selection highlight
|
||||
if (W.sel && W.sel.v) pushSpr(a, W.sel.v, [1, 0.85, 0.45, 1], 34, 9, -10, -1e9, 1e9, 0, 0.5, 0.6);
|
||||
// cyclone cone fill (alpha)
|
||||
if (W.on('cyclones') && W.coneSprites) { const f = W.coneSprites.slice(); const kmPerPx = W.kmPerPx || 10; for (let i = 0; i < f.length; i += SPR_STRIDE) { f[i + 7] = clamp((f[i + 7] * 2) / kmPerPx, 1, 600) / Math.max(W.sizeK, 0.1); } al.push(...f); }
|
||||
uploadDyn('dynBuf', 'dynVao', 'dynCap', 'dynCount', a, 'sprite');
|
||||
uploadDyn('alphaBuf', 'alphaVao', 'alphaCap', 'alphaCount', al, 'sprite');
|
||||
// dynamic lines: ISS ground track, selected route, extra lines from UI
|
||||
if (W.on('tracks')) {
|
||||
const pts = []; for (let k = -46; k <= 46; k++) { const tt = t + k * 60; const s = astro.sun(NOW + tt * 1000); const p = satAt(GE.ISS, tt, s.gmst); pts.push(v3.scale(v3.norm(p), 1.0005)); }
|
||||
pushPolyline(ln, pts, [0.79, 0.66, 1, 0.55], 1);
|
||||
const op = []; for (let k = 0; k <= 64; k++) { const tt = t + (k / 64) * (TAU / GE.ISS.n); op.push(satAt(GE.ISS, tt, W.sun.gmst)); } pushPolyline(ln, op, [0.79, 0.66, 1, 0.4]);
|
||||
}
|
||||
if (W.selRoute) pushPolyline(ln, W.selRoute, [1, 0.85, 0.45, 0.8], 1);
|
||||
for (const extra of W.extraLines) pushPolyline(ln, extra.pts, extra.col, extra.dashed || 0);
|
||||
for (const hh of W.userHolos) pushPolyline(ln, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.06)], [1, 0.85, 0.5, 0.9]);
|
||||
uploadDyn('dynLineBuf', 'dynLineVao', 'dynLineCap', 'dynLineCount', ln, 'line');
|
||||
};
|
||||
W.extraLines = [];
|
||||
|
||||
/* ---------- counts ---------- */
|
||||
W.updateCounts = function () {
|
||||
const t = W.t; let c0 = 0, c1 = 0, c3 = 0;
|
||||
for (const f of GE.FLIGHTS) { let ph = (t - f.t0) % f.period; if (ph < 0) ph += f.period; const lp = ph < f.period / 2 ? ph : ph - f.period / 2; if (lp <= f.dur) { if (f.kind === 0) c0++; else if (f.kind === 1) c1++; else c3++; } }
|
||||
const C = W.counts;
|
||||
C.flights = c0; C.military = c1; C.ga = c3; C.helis = HELI_CIRCUITS.length; C.squawk = W.squawks.length; C.drones = DRONES.length; C.airports = GE.AP.length; C.holding = HOLDERS.length;
|
||||
C.ais = GE.SHIPS.length; C.tankers = GE.SHIPS.filter((s) => s.kind === 'tanker' || s.kind === 'LNG').length;
|
||||
C.sats = GE.SATS.filter((s) => s.kind <= 3).length; C.crewed = 2; C.trains = 1; C.debris = GE.SATS.filter((s) => s.kind === 4).length; C.tracks = 1;
|
||||
C.fires = GE.FIRES.filter((f) => f.t <= t).length; C.quakes = GE.QUAKES.filter((q) => q.t <= t && q.t > t - 86400 * 3).length;
|
||||
C.cyclones = 1; C.lightning = 160 + Math.round(40 * Math.sin(t / 900)); C.wind = 1400; C.transit = 400; C.traffic = 1;
|
||||
for (const l of W.layers) l.count = C[l.id] ?? 0;
|
||||
bus.emit('layers', W.layers);
|
||||
};
|
||||
|
||||
/* ---------- textures ---------- */
|
||||
W.loadTextures = async function () {
|
||||
const T = window.__GE_TEX || {};
|
||||
const load = (src) => new Promise((res, rej) => { const im = new Image(); im.decoding = 'async'; im.onload = () => res(im); im.onerror = rej; im.src = src; });
|
||||
const [day, night, clouds, water, topo] = await Promise.all(['day', 'night', 'clouds', 'water', 'topo'].map((k) => load(T[k])));
|
||||
W.img = { day, night, clouds, water, topo };
|
||||
W.tex = { day: GL.texture(day), night: GL.texture(night), clouds: GL.texture(clouds), water: GL.texture(water, { aniso: 2 }), topo: GL.texture(topo, { mips: true, aniso: 0 }) };
|
||||
// CPU copies for density queries (lights) and thumbnails fallback
|
||||
const cv = document.createElement('canvas'); cv.width = 1024; cv.height = 512; const cx = cv.getContext('2d', { willReadFrequently: true });
|
||||
cx.drawImage(night, 0, 0, 1024, 512); W.nightPx = cx.getImageData(0, 0, 1024, 512).data;
|
||||
cx.drawImage(water, 0, 0, 1024, 512); W.waterPx = cx.getImageData(0, 0, 1024, 512).data;
|
||||
};
|
||||
W.lightsAt = function (lat, lon) {
|
||||
if (!W.nightPx) return 0;
|
||||
const x = ((lon + 180) / 360) * 1024, y = ((90 - lat) / 180) * 512;
|
||||
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
|
||||
const s = (i, j) => { i = ((i % 1024) + 1024) % 1024; j = clamp(j, 0, 511); const o = (j * 1024 + i) * 4; return Math.max(0, (W.nightPx[o + 1] * 1.1 - W.nightPx[o + 2] * 0.55) / 255); };
|
||||
return lerp(lerp(s(x0, y0), s(x0 + 1, y0), fx), lerp(s(x0, y0 + 1), s(x0 + 1, y0 + 1), fx), fy);
|
||||
};
|
||||
W.waterAt = function (lat, lon) { if (!W.waterPx) return 0; const x = Math.floor(((lon + 180) / 360) * 1024) % 1024, y = clamp(Math.floor(((90 - lat) / 180) * 512), 0, 511); return W.waterPx[(y * 1024 + x) * 4] / 255; };
|
||||
|
||||
/* ---------- lens ---------- */
|
||||
W.lens = 'cinematic';
|
||||
const LENS = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 };
|
||||
W.setLens = (id) => { W.lens = id; bus.emit('lens', id); };
|
||||
|
||||
/* ---------- view helpers ---------- */
|
||||
// view: {eye, right, up, back, fovY, aspect, near, far, shiftX, shiftY, w, h}
|
||||
W.matrices = function (view) {
|
||||
const P = m4.persp(view.fovY, view.aspect, view.near, view.far);
|
||||
P[8] = -(view.shiftX || 0); P[9] = -(view.shiftY || 0);
|
||||
const V = m4.viewRot(view.right, view.up, view.back);
|
||||
view.P = P; view.V = V; view.VP = m4.mul(P, V);
|
||||
return view;
|
||||
};
|
||||
W.project = function (view, p) { // world unit coords → css px {x,y,z,vis}
|
||||
const rel = v3.sub(p, view.eye); const c = m4.xform(view.VP, rel);
|
||||
if (c[3] <= 0) return null;
|
||||
const x = c[0] / c[3], y = c[1] / c[3];
|
||||
// occlusion by planet
|
||||
const E = view.eye, d = rel, L2 = v3.len(d), u = v3.scale(d, 1 / L2), b = v3.dot(E, u), cc = v3.dot(E, E) - 0.9985 * 0.9985, disc = b * b - cc;
|
||||
let occ = false; if (disc > 0) { const tn = -b - Math.sqrt(disc); occ = tn > 0 && tn < L2; }
|
||||
return { x: (x * 0.5 + 0.5) * view.w, y: (1 - (y * 0.5 + 0.5)) * view.h, d: L2, vis: !occ && Math.abs(x) < 1.2 && Math.abs(y) < 1.2 };
|
||||
};
|
||||
|
||||
/* ---------- render ---------- */
|
||||
W.quality = { scale: 1, steps: 12, bloom: true, grain: true };
|
||||
const LOD = { cctv: 0.07, street: 0.03, alpr: 0.035, bikes: 0.04, streams: 0.4, signed: 0.6, venues: 0.9, power: 0.7, dc: 1.6, dams: 1.6, milbase: 1.6, airports: 1.3, ports: 1.4, imagery: 1.6, fishing: 2.2, radar: 1.6 };
|
||||
W.render = function (view, chain, outFb, opts = {}) {
|
||||
const gl = GL.gl, PR = GL.progs;
|
||||
W.matrices(view);
|
||||
const sun = opts.sun || W.sun;
|
||||
const sunDir = sun.dir;
|
||||
const t = opts.t ?? W.t;
|
||||
const eye = view.eye, eyeR = v3.len(eye), alt = eyeR - 1;
|
||||
const eyeC = (eyeR - 1) * (eyeR + 1);
|
||||
const eyeH = eye.map(Math.fround), eyeL = [eye[0] - eyeH[0], eye[1] - eyeH[1], eye[2] - eyeH[2]];
|
||||
const lensId = opts.lens || W.lens; const mode = LENS[lensId] ?? 0;
|
||||
const range = view.range ?? alt;
|
||||
const tanY = Math.tan(view.fovY / 2), tanX = tanY * view.aspect;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, chain.scene.fb);
|
||||
gl.viewport(0, 0, chain.w, chain.h);
|
||||
gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.depthMask(true);
|
||||
gl.clearColor(0, 0, 0, 0); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
// ---- globe
|
||||
const G = PR.globe; gl.useProgram(G.p);
|
||||
gl.uniform3fv(G.u.uCamR, view.right); gl.uniform3fv(G.u.uCamU, view.up); gl.uniform3fv(G.u.uCamB, view.back);
|
||||
gl.uniform4f(G.u.uTanS, tanX, tanY, view.shiftX || 0, view.shiftY || 0);
|
||||
gl.uniform3fv(G.u.uEye, eye); gl.uniform1f(G.u.uEyeC, eyeC); gl.uniform1f(G.u.uT, t); gl.uniform1f(G.u.uGmst, sun.gmst);
|
||||
gl.uniform3fv(G.u.uSun, sunDir); gl.uniform1f(G.u.uSunI, 20.0);
|
||||
const cloudsOn = W.on('clouds') ? 1 : 0;
|
||||
gl.uniform4f(G.u.uLens, mode, mode === 2 ? 1 : opts.nightForce || 0, cloudsOn * (opts.cloudK ?? 1), mode === 1 ? 1 : 0);
|
||||
// cyclone
|
||||
const cyc = GE.CYCLONE.at(opts.offsetH ?? W.offsetH); const cv = geo.vec(cyc.lat, cyc.lon);
|
||||
gl.uniform4f(G.u.uCyc, cv[0], cv[1], cv[2], 5.2 * D2R);
|
||||
gl.uniform4f(G.u.uCycP, W.on('cyclones') ? clamp(cyc.wind / 190, 0.3, 1.0) : 0, (t / 3600) * 1.8, 0.16 * D2R, 0);
|
||||
gl.uniform1f(G.u.uAurora, opts.aurora ?? W.auroraK);
|
||||
// local detail frame
|
||||
const sc = opts.scene || W.activeScene(view);
|
||||
const org = sc ? sc : { lat: view.tLat ?? 0, lon: view.tLon ?? 0, block: 95, rot: 0.3, urban: 0 };
|
||||
const enu = geo.enu(org.lat, org.lon), ov = geo.vec(org.lat, org.lon);
|
||||
const de = v3.sub(eye, ov);
|
||||
const eyeLocal = [v3.dot(de, enu.east) * EARTH_M, v3.dot(de, enu.north) * EARTH_M, v3.dot(de, enu.up) * EARTH_M];
|
||||
gl.uniformMatrix3fv(G.u.uEnu, false, [...enu.east, ...enu.north, ...enu.up]);
|
||||
gl.uniform3fv(G.u.uEyeLocal, eyeLocal);
|
||||
const detail = smooth(0.012, 0.0025, range) * (opts.detail ?? 1);
|
||||
gl.uniform4f(G.u.uCity, detail, org.block || 95, org.rot || 0, org.urban || 0);
|
||||
gl.uniform1f(G.u.uRange, range);
|
||||
gl.uniform1i(G.u.uSteps, opts.steps || W.quality.steps);
|
||||
gl.uniform1f(G.u.uInside, eyeR < 1.0165 ? 1 : 0);
|
||||
const fo = opts.focus; if (fo) { const fv = geo.vec(fo.lat, fo.lon); gl.uniform4f(G.u.uFocus, fv[0], fv[1], fv[2], fo.k ?? 0.8); } else gl.uniform4f(G.u.uFocus, 0, 0, 1, 0);
|
||||
GL.bindTex(G, 'uDay', W.tex.day, 0); GL.bindTex(G, 'uNight', W.tex.night, 1); GL.bindTex(G, 'uClouds', W.tex.clouds, 2); GL.bindTex(G, 'uWater', W.tex.water, 3); GL.bindTex(G, 'uTopo', W.tex.topo, 4);
|
||||
gl.uniform4fv(G.u.uLocalWater, sc && sc.water ? sc.water : [0, 0, 0, 0]);
|
||||
gl.uniform1f(G.u.uTraffic, W.on('traffic') ? 1 : 0);
|
||||
GL.fsq(G);
|
||||
// ---- meshes
|
||||
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL);
|
||||
if (sc && sc.mesh && range < 0.03) GE.scenes.draw(sc, view, { sun, t, eye, opts });
|
||||
// ---- lines & sprites (additive)
|
||||
gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
|
||||
const altX = lerp(1, 16, smooth(0.008, 1.3, range)); W.altX = altX;
|
||||
const sizeK = opts.sizeK ?? clamp(lerp(0.5, 1.1, smooth(2.2, 0.05, range)), 0.4, 1.3); W.sizeK = sizeK; W.lastRange = range;
|
||||
const px = (opts.pxScale ?? 1) * (chain.w / view.w);
|
||||
const occR = 1 - clamp(alt * 0.004, 2e-7, 0.0015);
|
||||
const common = (P, alphaK = 1) => {
|
||||
gl.useProgram(P.p);
|
||||
gl.uniformMatrix4fv(P.u.uVP, false, view.VP); gl.uniform3fv(P.u.uEyeH, eyeH); gl.uniform3fv(P.u.uEyeL, eyeL);
|
||||
gl.uniform1f(P.u.uEyeC, eyeC); gl.uniform1f(P.u.uOccR, occR); gl.uniform1f(P.u.uT, t);
|
||||
if (P.u.uPx) gl.uniform1f(P.u.uPx, px); if (P.u.uAltX) gl.uniform1f(P.u.uAltX, altX); if (P.u.uSizeK) gl.uniform1f(P.u.uSizeK, sizeK);
|
||||
if (P.u.uGmst) gl.uniform1f(P.u.uGmst, sun.gmst); if (P.u.uView) gl.uniform2f(P.u.uView, view.w, view.h); if (P.u.uSun) gl.uniform3fv(P.u.uSun, sunDir);
|
||||
if (P.u.uMinSize) gl.uniform1f(P.u.uMinSize, 1.3); if (P.u.uGlyphPx) gl.uniform1f(P.u.uGlyphPx, range > 0.55 ? 9999 : 6 * px); if (P.u.uAlphaK) gl.uniform1f(P.u.uAlphaK, alphaK); if (P.u.uPass) gl.uniform1f(P.u.uPass, 0);
|
||||
};
|
||||
const lineK = opts.lineK ?? 1;
|
||||
const LP = PR.line; common(LP, lineK); gl.uniform1f(LP.u.uDashK, 1 / Math.max(range * 0.012, 2e-6));
|
||||
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99) && !(id === 'ais' && range > 1.0)) for (const s of W.static[id]) if (s.kind === 'line') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); }
|
||||
if (W.dynLineVao && W.dynLineCount) { gl.bindVertexArray(W.dynLineVao); gl.drawArrays(gl.LINES, 0, W.dynLineCount); }
|
||||
if (opts.extraLines) for (const s of opts.extraLines) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.LINES, 0, s.count); }
|
||||
// trails
|
||||
const kindMask = [W.on('flights') ? 1 : 0, W.on('military') ? 1 : 0, 0, W.on('ga') ? 1 : 0];
|
||||
const trailLen = range < 0.02 ? 240 : range < 0.2 ? 900 : 1500;
|
||||
if ((kindMask[0] || kindMask[1] || kindMask[3]) && opts.trails !== false) {
|
||||
const TP = PR.trail; common(TP, (range > 1.4 ? 0.22 : 0.4) * lineK); gl.uniform4fv(TP.u.uKindMask, kindMask); gl.uniform1f(TP.u.uTrail, 1); gl.uniform1f(TP.u.uTrailLen, trailLen); gl.uniform1f(TP.u.uSelId, W.selFlight ?? -1);
|
||||
gl.uniform4f(TP.u.uKindSize, 9, 10, 10, 7);
|
||||
gl.bindVertexArray(W.trailVao); gl.drawArraysInstanced(gl.LINE_STRIP, 0, W.trailK, W.nFlights);
|
||||
}
|
||||
// static sprite sets
|
||||
const SP = PR.sprite; common(SP);
|
||||
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'add') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
|
||||
if (W.dynVao && W.dynCount) { gl.bindVertexArray(W.dynVao); gl.drawArrays(gl.POINTS, 0, W.dynCount); }
|
||||
if (opts.extraSprites) for (const s of opts.extraSprites) { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
|
||||
// flights
|
||||
if (kindMask[0] || kindMask[1] || kindMask[3]) {
|
||||
const FP = PR.flight; common(FP); gl.uniform4fv(FP.u.uKindMask, kindMask); gl.uniform1f(FP.u.uTrail, 0); gl.uniform1f(FP.u.uTrailLen, 0); gl.uniform1f(FP.u.uSelId, W.selFlight ?? -1);
|
||||
gl.uniform4f(FP.u.uKindSize, 13, 15, 14, 10);
|
||||
gl.bindVertexArray(W.flightVao); gl.drawArraysInstanced(gl.POINTS, 0, 1, W.nFlights);
|
||||
}
|
||||
// satellites
|
||||
const sm0 = [W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0, W.on('sats') ? 1 : 0], sm1 = [W.on('debris') ? 1 : 0, W.on('trains') ? 1 : 0, W.on('crewed') ? 1 : 0, 0];
|
||||
if (sm0[0] || sm1[0] || sm1[1] || sm1[2]) {
|
||||
const AP2 = PR.sat; common(AP2, opts.satK ?? 1); gl.uniform4fv(AP2.u.uSatMask0, sm0); gl.uniform4fv(AP2.u.uSatMask1, sm1); gl.uniform1f(AP2.u.uSelId, W.selSat ?? -1);
|
||||
gl.bindVertexArray(W.satVao); gl.drawArrays(gl.POINTS, 0, W.nSats);
|
||||
}
|
||||
// alpha pass (smoke, AQ, radar, GNSS, cone)
|
||||
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE);
|
||||
common(SP); gl.uniform1f(SP.u.uPass, 1);
|
||||
for (const id in W.static) if (W.on(id) && range < (LOD[id] || 99)) for (const s of W.static[id]) if (s.kind === 'sprite' && s.pass === 'alpha') { gl.bindVertexArray(s.vao); gl.drawArrays(gl.POINTS, 0, s.count); }
|
||||
if (W.alphaVao && W.alphaCount) { gl.bindVertexArray(W.alphaVao); gl.drawArrays(gl.POINTS, 0, W.alphaCount); }
|
||||
gl.disable(gl.BLEND); gl.disable(gl.DEPTH_TEST); gl.depthMask(true);
|
||||
gl.bindVertexArray(null);
|
||||
// ---- post
|
||||
W.post(chain, outFb, opts.outW ?? view.w, opts.outH ?? view.h, mode, opts);
|
||||
};
|
||||
W.post = function (chain, outFb, outW, outH, mode, opts) {
|
||||
const gl = GL.gl, PR = GL.progs, lv = chain.lv;
|
||||
const bloomOn = (opts.bloom ?? W.quality.bloom) && mode !== 1;
|
||||
if (bloomOn) {
|
||||
const B = PR.bright; gl.useProgram(B.p);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, lv[0].a.fb); gl.viewport(0, 0, lv[0].a.w, lv[0].a.h);
|
||||
GL.bindTex(B, 'uSrc', chain.scene.tex, 0); gl.uniform2f(B.u.uTexel, 0.5 / chain.w, 0.5 / chain.h); gl.uniform1f(B.u.uThr, mode === 4 ? 1.2 : 3.2); GL.fsq(B);
|
||||
const Dn = PR.down;
|
||||
for (let i = 1; i < 4; i++) { gl.useProgram(Dn.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].a.fb); gl.viewport(0, 0, lv[i].a.w, lv[i].a.h); GL.bindTex(Dn, 'uSrc', lv[i - 1].a.tex, 0); gl.uniform2f(Dn.u.uTexel, 1 / lv[i - 1].a.w, 1 / lv[i - 1].a.h); GL.fsq(Dn); }
|
||||
const Up = PR.up;
|
||||
for (let i = 2; i >= 0; i--) { gl.useProgram(Up.p); gl.bindFramebuffer(gl.FRAMEBUFFER, lv[i].b.fb); gl.viewport(0, 0, lv[i].b.w, lv[i].b.h); const src = i === 2 ? lv[3].a : lv[i + 1].b; GL.bindTex(Up, 'uSrc', src.tex, 0); GL.bindTex(Up, 'uBase', lv[i].a.tex, 1); gl.uniform2f(Up.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(Up.u.uMix, 1.0); GL.fsq(Up); }
|
||||
}
|
||||
const Cp = PR.comp; gl.useProgram(Cp.p);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, outFb);
|
||||
gl.viewport(0, 0, opts.vpW ?? (outFb ? outW : gl.drawingBufferWidth), opts.vpH ?? (outFb ? outH : gl.drawingBufferHeight));
|
||||
GL.bindTex(Cp, 'uScene', chain.scene.tex, 0); GL.bindTex(Cp, 'uBloom', bloomOn ? lv[0].b.tex : lv[0].a.tex, 1);
|
||||
gl.uniform2f(Cp.u.uTexel, 1 / chain.w, 1 / chain.h);
|
||||
gl.uniform1f(Cp.u.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : 0.62));
|
||||
gl.uniform1f(Cp.u.uBloomK, bloomOn ? (mode === 4 ? 0.3 : 0.16) : 0);
|
||||
gl.uniform1f(Cp.u.uVig, opts.vig ?? (mode === 1 ? 0.25 : 0.9));
|
||||
gl.uniform1f(Cp.u.uGrain, (opts.grain ?? W.quality.grain) && mode !== 1 ? 0.022 : 0);
|
||||
gl.uniform1f(Cp.u.uT, performance.now() / 1000 % 100);
|
||||
gl.uniform1f(Cp.u.uFade, opts.fade ?? 1);
|
||||
gl.uniform1f(Cp.u.uFxaa, 1);
|
||||
gl.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.08);
|
||||
gl.uniform1i(Cp.u.uMode, mode);
|
||||
gl.uniform3fv(Cp.u.uLift, [0.00017, 0.00038, 0.001]);
|
||||
gl.uniform3fv(Cp.u.uGain, mode === 0 ? [1.02, 1.0, 0.97] : [1, 1, 1]);
|
||||
GL.fsq(Cp);
|
||||
};
|
||||
|
||||
/* ---------- active local scene (city / terrain) ---------- */
|
||||
W.activeScene = function (view) {
|
||||
if (!GE.scenes) return null;
|
||||
const range = view.range ?? 1; if (range > 0.03) return null;
|
||||
const [la, lo] = view.tLat != null ? [view.tLat, view.tLon] : geo.ll(view.eye);
|
||||
let best = null, bd = 1e9;
|
||||
for (const s of GE.scenes.list) { const d = geo.dist(la, lo, s.lat, s.lon) * EARTH_KM; if (d < s.radiusKm && d < bd) { bd = d; best = s; } }
|
||||
if (best) GE.scenes.ensure(best);
|
||||
return best;
|
||||
};
|
||||
|
||||
/* ---------- picking (CPU) ---------- */
|
||||
W.pick = function (view, x, y, radius = 26) {
|
||||
const cands = [];
|
||||
const consider = (p, item) => { const s = W.project(view, p); if (!s || !s.vis) return; const d = Math.hypot(s.x - x, s.y - y); if (d < radius) cands.push({ d: d - (item.prio || 0), ...item }); };
|
||||
const t = W.t;
|
||||
for (const p of W.situationPins) consider(geo.vec(p.lat, p.lon, 0.2), { kind: 'situation', id: p.id, title: p.title, prio: 10, lat: p.lat, lon: p.lon });
|
||||
if (W.on('flights') || W.on('military') || W.on('ga')) GE.FLIGHTS.forEach((f, i) => {
|
||||
if ((f.kind === 0 && !W.on('flights')) || (f.kind === 1 && !W.on('military')) || (f.kind === 3 && !W.on('ga'))) return;
|
||||
const p = flightAt(f, t); if (!p) return;
|
||||
consider(v3.scale(p.v, 1 + (p.altKm * W.altX) / EARTH_KM), { kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog }, prio: 2 });
|
||||
});
|
||||
if (W.on('helis')) HELI_CIRCUITS.forEach((hc, i) => { const p = heliAt(hc, t); consider(p.v, { kind: 'heli', id: i, title: hc.cs, subtitle: `${hc.type} · ${hc.field || 'orbit'} · ${Math.round(p.altKm * 3281)} ft`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, prio: 4 }); });
|
||||
if (W.on('ais') && W._shipCache) GE.SHIPS.forEach((s, i) => { const p = W._shipCache[i]; if (!p) return; consider(geo.vec(p.lat, p.lon, 0.03), { kind: 'ship', id: i, title: s.name, subtitle: `${s.kind} · ${p.sog ? p.sog.toFixed(1) + ' kn' : 'at anchor'} · MMSI ${s.mmsi}`, lat: p.lat, lon: p.lon, hdg: p.hdg }); });
|
||||
if (W.on('crewed')) [GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo] = geo.ll(p); consider(p, { kind: 'sat', id: GE.SATS.indexOf(s), title: s.name, subtitle: `${Math.round(s.alt)} km · ${(Math.sqrt(398600.4418 / (6371 + s.alt))).toFixed(2)} km/s`, lat: la, lon: lo, prio: 6 }); });
|
||||
if (W.on('quakes')) GE.QUAKES.forEach((q, i) => { if (q.t > t) return; consider(geo.vec(q.lat, q.lon, 0.5), { kind: 'quake', id: i, title: `M ${q.mag.toFixed(1)}`, subtitle: `${q.depth} km deep · ${fmt.ago(-q.t * 1000 + W.offsetH * 3600000)}`, lat: q.lat, lon: q.lon, prio: q.mag }); });
|
||||
if (W.on('holo') || W.on('signed')) HOLOS.forEach((hh, i) => consider(geo.vec(hh.lat, hh.lon, 0.2), { kind: 'holo', id: i, title: hh.title, subtitle: `Signed capture · @${hh.by} · illustrative`, lat: hh.lat, lon: hh.lon, prio: 5 }));
|
||||
cands.sort((a, b) => a.d - b.d);
|
||||
return cands[0] || null;
|
||||
};
|
||||
W.contactsNear = function (lat, lon, km) {
|
||||
const out = [], t = W.t;
|
||||
GE.FLIGHTS.forEach((f, i) => { const p = flightAt(f, t); if (!p) return; const d = geo.dist(lat, lon, p.lat, p.lon) * EARTH_KM; if (d < km) out.push({ kind: 'flight', id: i, title: f.cs, sub: f.type, lat: p.lat, lon: p.lon, altM: p.altKm * 1000, headingDeg: p.hdg * R2D, d }); });
|
||||
[GE.ISS, GE.CSS].forEach((s) => { const p = satAt(s, t, W.sun.gmst); const [la, lo, al] = geo.ll(p); const d = geo.dist(lat, lon, la, lo) * EARTH_KM; if (d < 2200) out.push({ kind: 'sat', title: s.name, sub: Math.round(al) + ' km', lat: la, lon: lo, altM: al * 1000, d }); });
|
||||
return out.sort((a, b) => a.d - b.d);
|
||||
};
|
||||
W.auroraK = 0.85;
|
||||
Object.assign(W, { heliAt, holdAt, droneAt, runnerAt, HOLDERS, RUN_V });
|
||||
@@ -0,0 +1,240 @@
|
||||
/* Local 3D scenes for the low-altitude "photoreal city" treatment: procedural extruded city blocks
|
||||
aligned with the globe shader's street grid, stadium bowl, airfields, launch pad, and a Himalayan
|
||||
valley heightfield for the Bhote Koshi reconstruction (river carved along the real corridor). */
|
||||
const SC = (GE.scenes = {});
|
||||
const VSTR = 12; // pos3 nor3 uv2 attr4
|
||||
SC.list = [
|
||||
{ id: 'nyc', name: 'Midtown Manhattan', lat: 40.7549, lon: -73.9840, block: 88, rot: 29 * D2R, urban: 0.55, radiusKm: 16, kind: 'city', R: 3000, maxH: 240, tower: { lat: 40.7553, lon: -73.9838, h: 412, w: 46 } },
|
||||
{ id: 'chi', name: 'Chicago Loop', lat: 41.8789, lon: -87.6359, block: 100, rot: 0, urban: 0.45, radiusKm: 18, kind: 'city', R: 2600, maxH: 260, water: [0.94, 0.342, 1824, 1], downtown: [41.8807, -87.6293] },
|
||||
{ id: 'wembley', name: 'Wembley', lat: 51.5560, lon: -0.2796, block: 74, rot: 0.21, urban: 0.25, radiusKm: 10, kind: 'stadium', R: 1500, maxH: 40 },
|
||||
{ id: 'novosel', name: 'Cairns Army Airfield', lat: 31.2760, lon: -85.7130, block: 140, rot: 0.06, urban: -0.2, radiusKm: 28, kind: 'airfield', R: 0 },
|
||||
{ id: 'koshi', name: 'Bhote Koshi corridor', lat: 28.2150, lon: 85.3450, block: 100, rot: 0, urban: -1, radiusKm: 32, kind: 'terrain', R: 0 },
|
||||
{ id: 'cape', name: 'SLC-40', lat: 28.5618, lon: -80.5772, block: 120, rot: 0, urban: -0.5, radiusKm: 14, kind: 'pad', R: 0 },
|
||||
{ id: 'drone', name: 'Drone corridor', lat: 30.5880, lon: -96.2900, block: 62, rot: 0.12, urban: 0.3, radiusKm: 12, kind: 'suburb', R: 2200, maxH: 9 },
|
||||
{ id: 'atl', name: 'ATL', lat: 33.6407, lon: -84.4277, block: 120, rot: 0, urban: 0.0, radiusKm: 10, kind: 'airport', R: 0 },
|
||||
];
|
||||
SC.byId = Object.fromEntries(SC.list.map((s) => [s.id, s]));
|
||||
// Bhote Koshi corridor (approximate public geography): Rasuwagadhi → Timure → Syaphrubesi → Trishuli
|
||||
const KOSHI = [[28.2860, 85.3815], [28.2802, 85.3797], [28.2700, 85.3752], [28.2555, 85.3705], [28.2400, 85.3650], [28.2250, 85.3600], [28.2100, 85.3550], [28.1950, 85.3500], [28.1800, 85.3460], [28.1660, 85.3420], [28.1600, 85.3380], [28.1500, 85.3230], [28.1400, 85.3060], [28.1300, 85.2900]];
|
||||
SC.KOSHI = KOSHI;
|
||||
SC.FLOOD = { startMs: Date.UTC(2026, 7, 26, 7, 45), speedMH: 18000, crest: 9 };
|
||||
|
||||
function Mesh() { this.v = []; this.i = []; }
|
||||
Mesh.prototype.vert = function (p, n, uv, at) { this.v.push(p[0], p[1], p[2], n[0], n[1], n[2], uv[0], uv[1], at[0], at[1], at[2], at[3]); return this.v.length / VSTR - 1; };
|
||||
Mesh.prototype.quad = function (a, b, c, d, n, uvs, at) { const i0 = this.vert(a, n, uvs[0], at), i1 = this.vert(b, n, uvs[1], at), i2 = this.vert(c, n, uvs[2], at), i3 = this.vert(d, n, uvs[3], at); this.i.push(i0, i1, i2, i0, i2, i3); };
|
||||
// oriented box: center (x,y) in ENU m, size w×d, height h from z0, rotation rot
|
||||
Mesh.prototype.box = function (x, y, w, d, h, rot, at, z0 = 0) {
|
||||
if (at[0] === 4) at = [4, at[1], at[2], d];
|
||||
const c = Math.cos(rot), s = Math.sin(rot);
|
||||
const P = (u, v) => [x + u * c - v * s, y + u * s + v * c];
|
||||
const pts = [P(-w / 2, -d / 2), P(w / 2, -d / 2), P(w / 2, d / 2), P(-w / 2, d / 2)];
|
||||
const z1 = z0 + h;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const a = pts[k], b = pts[(k + 1) % 4];
|
||||
const ex = b[0] - a[0], ey = b[1] - a[1], L = Math.hypot(ex, ey);
|
||||
const n = [ey / L, -ex / L, 0];
|
||||
this.quad([a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1], n, [[0, 0], [L, 0], [L, h], [0, h]], at);
|
||||
}
|
||||
this.quad([pts[0][0], pts[0][1], z1], [pts[1][0], pts[1][1], z1], [pts[2][0], pts[2][1], z1], [pts[3][0], pts[3][1], z1], [0, 0, 1], [[0, 0], [w, 0], [w, d], [0, d]], at);
|
||||
};
|
||||
|
||||
const enuOf = (sc, lat, lon) => { const e = geo.enu(sc.lat, sc.lon); const d = v3.sub(geo.vec(lat, lon), geo.vec(sc.lat, sc.lon)); return [v3.dot(d, e.east) * EARTH_M, v3.dot(d, e.north) * EARTH_M]; };
|
||||
SC.enuOf = enuOf;
|
||||
const llOf = (sc, x, y) => geo.offset(sc.lat, sc.lon, x, y);
|
||||
|
||||
function genCity(sc, M, R0) {
|
||||
const B = sc.block, c = Math.cos(sc.rot), s = Math.sin(sc.rot);
|
||||
const rr = rng(sc.id.length * 991 + 7);
|
||||
const R = sc.R, n = Math.ceil(R / B) + 1;
|
||||
const dt = sc.downtown ? enuOf(sc, sc.downtown[0], sc.downtown[1]) : [0, 0];
|
||||
const twr = sc.tower ? enuOf(sc, sc.tower.lat, sc.tower.lon) : null;
|
||||
const hsh = (i, j) => hash01(i * 157.31 + j * 311.7 + sc.id.length);
|
||||
for (let i = -n; i < n; i++) for (let j = -n; j < n; j++) {
|
||||
const pcx = (i + 0.5) * B, pcy = (j + 0.5) * B;
|
||||
const qx = c * pcx - s * pcy, qy = s * pcx + c * pcy;
|
||||
const rd = Math.hypot(qx, qy); if (rd > R) continue;
|
||||
if (sc.water && qx * sc.water[0] + qy * sc.water[1] > sc.water[2] - 40) continue;
|
||||
if (sc.kind === 'stadium' && Math.hypot(qx, qy) < 280) continue;
|
||||
if (twr && Math.hypot(qx - twr[0], qy - twr[1]) < 40) continue;
|
||||
const [la, lo] = llOf(sc, qx, qy);
|
||||
if (GE.world.waterAt(la, lo) > 0.6 && !sc.water) continue;
|
||||
const dens = clamp(GE.world.lightsAt(la, lo) * 0.9 + sc.urban, 0, 1) * smooth(R, R * 0.6, rd);
|
||||
if (hsh(i, j) > dens * 1.25 + 0.05) continue;
|
||||
const rw = (0.085 + ((((i % 5) + 5) % 5) === 4 ? 0.05 : 0)) * B + 3;
|
||||
const inner = B - 2 * rw;
|
||||
const kx = inner > 50 && rr() < 0.6 ? 2 : 1, ky = inner > 50 && rr() < 0.6 ? 2 : 1;
|
||||
const ddt = Math.hypot(qx - dt[0], qy - dt[1]);
|
||||
for (let a = 0; a < kx; a++) for (let b = 0; b < ky; b++) {
|
||||
const lw = inner / kx, ld = inner / ky;
|
||||
const lpx = i * B + rw + lw * (a + 0.5), lpy = j * B + rw + ld * (b + 0.5);
|
||||
const x = c * lpx - s * lpy, y = s * lpx + c * lpy;
|
||||
const hh = hash01(lpx * 0.37 + lpy * 0.71);
|
||||
const core = Math.exp(-ddt / (sc.R * 0.28));
|
||||
let h = sc.kind === 'suburb' ? 5 + hh * 4 : 9 + Math.pow(dens, 1.6) * (sc.maxH * (0.15 + 0.85 * core)) * Math.exp(rr.gauss() * 0.45);
|
||||
h = clamp(h, 6, sc.maxH * 1.4);
|
||||
const inset = 2 + hh * 3;
|
||||
const type = sc.kind === 'suburb' ? 6 : 0;
|
||||
M.box(x, y, lw - inset * 2, ld - inset * 2, h, sc.rot, [type, hh, h, 0]);
|
||||
if (type === 0 && h > 60 && hh > 0.5) M.box(x, y, (lw - inset * 2) * 0.6, (ld - inset * 2) * 0.6, h * 0.25, sc.rot, [0, hh, h * 1.25, 0], h);
|
||||
}
|
||||
}
|
||||
if (twr) { // the fictional tower (skyscraper climb reconstruction)
|
||||
const T = sc.tower; M.box(twr[0], twr[1], T.w, T.w, T.h * 0.62, sc.rot, [0, 0.8, T.h, 0]); M.box(twr[0], twr[1], T.w * 0.72, T.w * 0.72, T.h * 0.3, sc.rot, [0, 0.8, T.h, 0], T.h * 0.62); M.box(twr[0], twr[1], 3, 3, T.h * 0.08, sc.rot, [5, 0.5, T.h, 0], T.h * 0.92);
|
||||
}
|
||||
}
|
||||
function genStadium(sc, M) {
|
||||
const N = 72; const rings = [[64, 46, 4], [92, 74, 20], [128, 108, 44]];
|
||||
for (let k = 0; k < N; k++) {
|
||||
const a0 = (k / N) * TAU, a1 = ((k + 1) / N) * TAU;
|
||||
for (let r = 0; r < rings.length - 1; r++) {
|
||||
const [ax, ay, az] = rings[r], [bx, by, bz] = rings[r + 1];
|
||||
const p = (A, ex, ey, ez) => [Math.cos(A) * ex, Math.sin(A) * ey, ez];
|
||||
const p0 = p(a0, ax, ay, az), p1 = p(a1, ax, ay, az), p2 = p(a1, bx, by, bz), p3 = p(a0, bx, by, bz);
|
||||
const e1 = v3.sub(p1, p0), e2 = v3.sub(p3, p0); const n = v3.norm(v3.cross(e1, e2));
|
||||
M.quad(p0, p1, p2, p3, n[2] < 0 ? v3.scale(n, -1) : n, [[k * 4, r * 20], [k * 4 + 4, r * 20], [k * 4 + 4, r * 20 + 20], [k * 4, r * 20 + 20]], [3, 0.5, 40, 0]);
|
||||
}
|
||||
// outer facade
|
||||
const [ox, oy] = [134, 114]; const o0 = [Math.cos(a0) * ox, Math.sin(a0) * oy], o1 = [Math.cos(a1) * ox, Math.sin(a1) * oy];
|
||||
const L = Math.hypot(o1[0] - o0[0], o1[1] - o0[1]); const nn = v3.norm([o0[0] + o1[0], o0[1] + o1[1], 0]);
|
||||
M.quad([o0[0], o0[1], 0], [o1[0], o1[1], 0], [o1[0], o1[1], 50], [o0[0], o0[1], 50], nn, [[0, 0], [L, 0], [L, 50], [0, 50]], [0, 0.75, 50, 0]);
|
||||
// roof ring
|
||||
const r0 = [Math.cos(a0) * 104, Math.sin(a0) * 86], r1 = [Math.cos(a1) * 104, Math.sin(a1) * 86];
|
||||
M.quad([r0[0], r0[1], 52], [r1[0], r1[1], 52], [o1[0] * 1.02, o1[1] * 1.02, 52], [o0[0] * 1.02, o0[1] * 1.02, 52], [0, 0, 1], [[0, 0], [1, 0], [1, 1], [0, 1]], [0, 0.6, 52, 0]);
|
||||
}
|
||||
M.quad([-55, -36, 0.6], [55, -36, 0.6], [55, 36, 0.6], [-55, 36, 0.6], [0, 0, 1], [[0, 0], [110, 0], [110, 72], [0, 72]], [4, 1, 0, 0]);
|
||||
M.box(-40, 0, 14, 30, 13, 0, [5, 0.5, 13, 0], 0.6);
|
||||
for (let k = 0; k < 6; k++) M.box(-40, -12 + k * 5, 1.2, 1.2, 22, 0, [5, 0.5, 22, 0], 13);
|
||||
}
|
||||
function genAirfield(sc, M) {
|
||||
const rw = (x, y, L, W, rot) => M.box(x, y, L, W, 0.4, rot, [4, 0, 0.4, 0]);
|
||||
rw(0, 0, 1550, 46, 0.06); rw(150, 100, 1500, 46, 0.06 + 1.1); rw(-300, -420, 900, 30, 0.06 + Math.PI / 2);
|
||||
for (let i = 0; i < 26; i++) { const x = -600 + (i % 13) * 95, y = 260 + Math.floor(i / 13) * 70; M.box(x, y, 28, 28, 0.35, 0.06, [4, 0, 0.3, 0]); }
|
||||
for (let i = 0; i < 6; i++) M.box(-500 + i * 120, 420, 80, 48, 14, 0.06, [0, 0.4 + i * 0.05, 14, 0]);
|
||||
M.box(260, -180, 14, 14, 26, 0.06, [0, 0.9, 26, 0]);
|
||||
// stagefields
|
||||
for (const [la, lo] of [[31.36, -85.63], [31.20, -85.81], [31.42, -85.79]]) { const [x, y] = enuOf(sc, la, lo); M.box(x, y, 700, 40, 0.4, 0.4, [4, 0, 0.4, 0]); for (let k = 0; k < 6; k++) M.box(x - 250 + k * 100, y + 80, 26, 26, 0.35, 0.4, [4, 0, 0.3, 0]); }
|
||||
}
|
||||
function genPad(sc, M) {
|
||||
M.box(0, 0, 220, 160, 0.5, 0, [4, 0, 0.5, 0]);
|
||||
M.box(-14, 0, 12, 12, 88, 0, [0, 0.55, 88, 0]);
|
||||
M.box(4, 0, 4.2, 4.2, 70, 0, [0, 0.98, 70, 0]);
|
||||
M.box(4, 0, 4.4, 4.4, 2, 0, [5, 0.5, 2, 0], 70);
|
||||
for (const [x, y, w, d, h] of [[300, 180, 60, 40, 22], [-260, 220, 90, 30, 12], [400, -260, 30, 30, 14]]) M.box(x, y, w, d, h, 0, [0, 0.4, h, 0]);
|
||||
}
|
||||
function genAirport(sc, M) {
|
||||
const rw = [[-1000, 1200, 3700], [-1100, 950, 2700], [-900, -300, 2700], [-1000, -560, 3000], [-1150, -1200, 2900]];
|
||||
for (const [x, y, L] of rw) M.box(x + L / 2 - 1000, y, L, 46, 0.4, 0, [4, 0, 0.4, 0]);
|
||||
for (let k = 0; k < 7; k++) M.box(-1100 + k * 420, 330, 46, 620, 16, 0, [0, 0.35, 16, 0]);
|
||||
M.box(-1500, 330, 260, 220, 22, 0, [0, 0.6, 22, 0]);
|
||||
M.box(1400, 1400, 14, 14, 70, 0, [0, 0.9, 70, 0]);
|
||||
}
|
||||
// Bhote Koshi valley heightfield
|
||||
function koshiRiverXY(sc) { return KOSHI.map(([la, lo]) => enuOf(sc, la, lo)); }
|
||||
function genTerrain(sc, M) {
|
||||
const riv = koshiRiverXY(sc);
|
||||
const cum = [0]; for (let i = 1; i < riv.length; i++) cum.push(cum[i - 1] + Math.hypot(riv[i][0] - riv[i - 1][0], riv[i][1] - riv[i - 1][1]));
|
||||
sc.riverLen = cum[cum.length - 1];
|
||||
const nearest = (x, y) => { let bd = 1e12, bs = 0; for (let i = 1; i < riv.length; i++) { const [ax, ay] = riv[i - 1], [bx, by] = riv[i]; const ex = bx - ax, ey = by - ay; const L2 = ex * ex + ey * ey; const t = clamp(((x - ax) * ex + (y - ay) * ey) / L2, 0, 1); const px = ax + ex * t - x, py = ay + ey * t - y; const d = px * px + py * py; if (d < bd) { bd = d; bs = cum[i - 1] + t * Math.sqrt(L2); } } return [Math.sqrt(bd), bs]; };
|
||||
const bed = (s) => 1880 - s * 0.021;
|
||||
const ridged = (x, y) => { let a = 0.5, f = 1 / 5200, sum = 0; for (let o = 0; o < 6; o++) { const n = 1 - Math.abs(vn2(x * f + o * 13.1, y * f + o * 7.7) * 2 - 1); sum += n * n * a; a *= 0.5; f *= 2.05; } return sum; };
|
||||
const height = (x, y) => {
|
||||
const [d, s] = nearest(x, y);
|
||||
const base = 1750 + ridged(x, y) * 2900 + y * 0.03 + Math.max(0, x) * 0.035;
|
||||
const b = bed(s);
|
||||
const k = Math.pow(clamp(d / 2600, 0, 1), 0.72);
|
||||
return [b + (base - b) * k + Math.min(d, 60) * 0.05, d, s];
|
||||
};
|
||||
sc.height = (x, y) => height(x, y)[0];
|
||||
const N = 180, HALF = 19000; const step = (2 * HALF) / N;
|
||||
const H = new Float32Array((N + 1) * (N + 1)), Dd = new Float32Array((N + 1) * (N + 1)), Ss = new Float32Array((N + 1) * (N + 1));
|
||||
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) { const x = -HALF + i * step, y = -HALF + j * step; const [h, d, s] = height(x, y); const k = j * (N + 1) + i; H[k] = h; Dd[k] = d; Ss[k] = s; }
|
||||
const base = M.v.length / VSTR;
|
||||
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {
|
||||
const k = j * (N + 1) + i; const x = -HALF + i * step, y = -HALF + j * step;
|
||||
const hl = H[j * (N + 1) + Math.max(0, i - 1)], hr = H[j * (N + 1) + Math.min(N, i + 1)], hd = H[Math.max(0, j - 1) * (N + 1) + i], hu = H[Math.min(N, j + 1) * (N + 1) + i];
|
||||
const n = v3.norm([(hl - hr) / (2 * step), (hd - hu) / (2 * step), 1]);
|
||||
const edge = Math.min(i, j, N - i, N - j); const z = edge === 0 ? 0 : H[k];
|
||||
M.vert([x, y, z], n, [Ss[k], 0], [1, hash01(k), H[k], smooth(260, 40, Dd[k])]);
|
||||
}
|
||||
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const a = base + j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1; M.i.push(a, b, d, a, d, c); }
|
||||
// river / flood water strip
|
||||
const wbase = M.v.length / VSTR; const pts = [];
|
||||
for (let s = 0; s <= sc.riverLen; s += 70) { let i = 1; while (i < cum.length - 1 && cum[i] < s) i++; const f = (s - cum[i - 1]) / (cum[i] - cum[i - 1]); const x = lerp(riv[i - 1][0], riv[i][0], f), y = lerp(riv[i - 1][1], riv[i][1], f); const tx = riv[i][0] - riv[i - 1][0], ty = riv[i][1] - riv[i - 1][1], tl = Math.hypot(tx, ty); pts.push([x, y, -ty / tl, tx / tl, s]); }
|
||||
for (const [x, y, nx, ny, s] of pts) { const W2 = 210; const z = bed(s) + 4; M.vert([x - nx * W2, y - ny * W2, z], [0, 0, 1], [s, -1], [2, 0, 0, 0]); M.vert([x + nx * W2, y + ny * W2, z], [0, 0, 1], [s, 1], [2, 0, 0, 0]); }
|
||||
for (let k = 0; k < pts.length - 1; k++) { const a = wbase + k * 2; M.i.push(a, a + 1, a + 3, a, a + 3, a + 2); }
|
||||
// villages & bridge
|
||||
const village = (la, lo, n, spread) => { const [cx, cy] = enuOf(sc, la, lo); const r = rng(Math.round(la * 1000)); for (let k = 0; k < n; k++) { const x = cx + r.gauss() * spread, y = cy + r.gauss() * spread; const z = sc.height(x, y); M.box(x, y, 7 + r() * 6, 8 + r() * 6, 5 + r() * 4, r() * 3, [6, r(), 6, 0], z - 1); } };
|
||||
village(28.1600, 85.3395, 70, 130); village(28.2560, 85.3690, 22, 80); village(28.2790, 85.3770, 14, 60); village(28.2050, 85.3520, 10, 60);
|
||||
const [bx, by] = enuOf(sc, 28.2802, 85.3797); M.box(bx, by, 6, 120, 2.5, 0.6, [0, 0.9, 3, 0], bed(sc.riverLen * 0.03) + 18);
|
||||
const [bx2, by2] = enuOf(sc, 28.1625, 85.3405); M.box(bx2, by2, 5, 110, 2.5, 1.0, [0, 0.9, 3, 0], bed(sc.riverLen * 0.83) + 16);
|
||||
sc.riverPts = pts; sc.bed = bed;
|
||||
}
|
||||
function vn2(x, y) { const i = Math.floor(x), j = Math.floor(y), fx = x - i, fy = y - j; const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); const h = (a, b) => hash01(a * 12.9898 + b * 78.233); return lerp(lerp(h(i, j), h(i + 1, j), u), lerp(h(i, j + 1), h(i + 1, j + 1), u), v); }
|
||||
|
||||
SC.ensure = function (sc) {
|
||||
if (sc.mesh || sc.building) return;
|
||||
sc.building = true;
|
||||
const M = new Mesh();
|
||||
if (sc.kind === 'city' || sc.kind === 'suburb' || sc.kind === 'stadium') genCity(sc, M);
|
||||
if (sc.kind === 'stadium') genStadium(sc, M);
|
||||
if (sc.kind === 'airfield') genAirfield(sc, M);
|
||||
if (sc.kind === 'pad') genPad(sc, M);
|
||||
if (sc.kind === 'airport') genAirport(sc, M);
|
||||
if (sc.kind === 'terrain') genTerrain(sc, M);
|
||||
if (sc.kind === 'suburb') M.box(0, 0, 130, 60, 12, sc.rot, [0, 0.3, 12, 0]);
|
||||
const gl = GL.gl, P = GL.progs.mesh;
|
||||
const vb = GL.buffer(new Float32Array(M.v));
|
||||
const ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(M.i), gl.STATIC_DRAW);
|
||||
sc.vao = GL.vao(P, { aPos: [vb, 3, VSTR, 0], aNor: [vb, 3, VSTR, 3], aUV: [vb, 2, VSTR, 6], aAttr: [vb, 4, VSTR, 8] }, ib);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||
sc.count = M.i.length; sc.mesh = true; sc.building = false;
|
||||
sc.enu = geo.enu(sc.lat, sc.lon); sc.origin = geo.vec(sc.lat, sc.lon);
|
||||
};
|
||||
|
||||
/* ---------- JS atmosphere (matches the shader) for mesh lighting & fog ---------- */
|
||||
const HR = 0.0021, HM = 0.0006, RA = 1.0165, BR = [22.1, 51.5, 126.3], BMk = 42.0;
|
||||
function odH(r, mu, H) { const c = Math.sqrt(Math.PI * 0.5 * r / H), h = (r - 1) / H; if (mu >= 0) return H * Math.exp(-h) * c / (c * mu + 1); const r0 = r * Math.sqrt(Math.max(0, 1 - mu * mu)); if (r0 < 1) return 1e4; const h0 = (r0 - 1) / H, c0 = Math.sqrt(Math.PI * 0.5 * r0 / H); return H * (2 * Math.exp(-h0) * c0 - Math.exp(-h) * c / (-c * mu + 1)); }
|
||||
function sunTransJS(x, sun) { const r = v3.len(x), mu = v3.dot(x, sun) / r; const oR = odH(r, mu, HR), oM = odH(r, mu, HM); return BR.map((b) => Math.exp(-(b * oR + BMk * 1.11 * oM))); }
|
||||
function atmoJS(ro, rd, tEnd, sun, steps = 10) {
|
||||
const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - RA * RA, d = b * b - c; if (d < 0) return [0, 0, 0];
|
||||
const sq = Math.sqrt(d); const t0 = Math.max(-b - sq, 0), t1 = Math.min(-b + sq, tEnd); if (t1 <= t0) return [0, 0, 0];
|
||||
const mu = v3.dot(rd, sun), pR = (3 / (16 * Math.PI)) * (1 + mu * mu), g = 0.76, pM = (3 / (8 * Math.PI)) * ((1 - g * g) * (1 + mu * mu)) / ((2 + g * g) * Math.pow(1 + g * g - 2 * g * mu, 1.5));
|
||||
let sR = [0, 0, 0], sM = [0, 0, 0], oR = 0, oM = 0; const L = t1 - t0;
|
||||
for (let i = 0; i < steps; i++) { const fa = i / steps, fb = (i + 1) / steps; const ta = fa * fa, tb = fb * fb; const ds = (tb - ta) * L; const t = t0 + (ta + tb) * 0.5 * L; const x = v3.madd(ro, rd, t); const r = v3.len(x), h = r - 1; const dR = Math.exp(-h / HR), dM = Math.exp(-h / HM); oR += dR * ds; oM += dM * ds; const muS = v3.dot(x, sun) / r; const lR = odH(r, muS, HR), lM = odH(r, muS, HM); for (let k = 0; k < 3; k++) { const att = Math.exp(-(BR[k] * (oR + lR) + BMk * 1.11 * (oM + lM))); sR[k] += dR * att * ds; sM[k] += dM * att * ds; } }
|
||||
return [0, 1, 2].map((k) => 20 * (sR[k] * BR[k] * pR + sM[k] * BMk * pM));
|
||||
}
|
||||
SC.atmoJS = atmoJS; SC.sunTransJS = sunTransJS;
|
||||
|
||||
SC.draw = function (sc, view, { sun, t, eye, opts }) {
|
||||
const gl = GL.gl, P = GL.progs.mesh; if (!sc.vao) return;
|
||||
const e = sc.enu, o = sc.origin, R = EARTH_M;
|
||||
const d = v3.sub(o, eye);
|
||||
const M = [e.east[0] / R, e.east[1] / R, e.east[2] / R, 0, e.north[0] / R, e.north[1] / R, e.north[2] / R, 0, e.up[0] / R, e.up[1] / R, e.up[2] / R, 0, d[0], d[1], d[2], 1];
|
||||
const MVP = m4.mul(view.VP, M);
|
||||
gl.useProgram(P.p);
|
||||
gl.uniformMatrix4fv(P.u.uMVP, false, MVP);
|
||||
const sd = sun.dir; const sunL = [v3.dot(sd, e.east), v3.dot(sd, e.north), v3.dot(sd, e.up)];
|
||||
gl.uniform3fv(P.u.uSunL, sunL);
|
||||
const tr = sunTransJS(v3.scale(o, 1.0002), sd);
|
||||
const k = 20 / Math.PI; const night = opts.nightForce ? 1 : 1 - smooth(-0.14, 0.06, sunL[2]);
|
||||
const clear = (opts.lens || GE.world.lens) === 'clear';
|
||||
gl.uniform3fv(P.u.uSunCol, clear ? [2.2, 2.2, 2.2] : tr.map((x) => x * k * (opts.nightForce ? 0.03 : 1)));
|
||||
const amb = smooth(-0.25, 0.5, sunL[2]);
|
||||
gl.uniform3fv(P.u.uSkyCol, clear ? [1.2, 1.25, 1.35] : [0.03 * 20 / Math.PI * 1.6 * amb + 0.014, 0.045 * 20 / Math.PI * 1.6 * amb + 0.02, 0.085 * 20 / Math.PI * 1.6 * amb + 0.04]);
|
||||
// fog colour: inscatter along the horizon in the camera's look direction
|
||||
const fwd = v3.scale(view.back, -1); const fh = v3.norm(v3.madd(fwd, e.up, -v3.dot(fwd, e.up))); const ro = v3.madd(o, e.up, 0.0003);
|
||||
let fog = atmoJS(ro, fh, 0.012, sd, 8); fog = fog.map((x) => x * 0.8 + 0.0015);
|
||||
gl.uniform3fv(P.u.uFogCol, fog);
|
||||
const el = [v3.dot(v3.scale(d, -1), e.east) * R, v3.dot(v3.scale(d, -1), e.north) * R, v3.dot(v3.scale(d, -1), e.up) * R];
|
||||
gl.uniform3fv(P.u.uEyeL, el);
|
||||
gl.uniform1f(P.u.uNight, night); gl.uniform1f(P.u.uT, t);
|
||||
gl.uniform1f(P.u.uVis, sc.kind === 'terrain' ? 110000 : 26000);
|
||||
const fl = SC.FLOOD; const simMs = opts.simMs ?? GE.world.simMs;
|
||||
gl.uniform4f(P.u.uFlood, (simMs - fl.startMs) / 3600000, 0, fl.speedMH, fl.crest);
|
||||
gl.uniform1f(P.u.uClear, clear ? 1 : 0);
|
||||
gl.bindVertexArray(sc.vao);
|
||||
gl.drawElements(gl.TRIANGLES, sc.count, gl.UNSIGNED_INT, 0);
|
||||
gl.bindVertexArray(null);
|
||||
};
|
||||
// ground height (m) at lat/lon for scenes with terrain
|
||||
SC.groundAt = function (lat, lon) { const sc = SC.byId.koshi; if (!sc.height) return 0; const [x, y] = enuOf(sc, lat, lon); return sc.height(x, y); };
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Camera: orbit (target lat/lon, range, heading, tilt, lens shift) with touch/pinch/wheel and
|
||||
inertia; fly-to along eased great-circle arcs with a log-space altitude hump (van Wijk-style);
|
||||
free rigs (ride / look / narrative) with smooth hand-off. */
|
||||
const Cam = (GE.cam = {
|
||||
lat: 18, lon: 40, range: 2.7, heading: 0, tilt: 0, shiftX: 0, shiftY: 0, fovMin: 40 * D2R,
|
||||
anim: null, vel: null, rig: null, rigBlend: 0, rigFrom: null, minRange: 0.00007, maxRange: 6,
|
||||
interacting: false, lastInput: 0, enabled: true,
|
||||
});
|
||||
Cam.maxTilt = (r) => lerp(0.0, 1.32, smooth(1.4, 0.004, r));
|
||||
Cam.basis = function () {
|
||||
const T = geo.vec(Cam.lat, Cam.lon, Cam.targetAltKm || 0);
|
||||
const { east, north, up } = geo.enu(Cam.lat, Cam.lon);
|
||||
const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading);
|
||||
const F = v3.add(v3.scale(north, ch), v3.scale(east, sh));
|
||||
const tilt = Math.min(Cam.tilt, Cam.maxTilt(Cam.range));
|
||||
const off = v3.norm(v3.add(v3.scale(up, Math.cos(tilt)), v3.scale(F, -Math.sin(tilt))));
|
||||
const eye = v3.madd(T, off, Cam.range);
|
||||
const upv = v3.norm(v3.add(v3.scale(F, Math.cos(tilt)), v3.scale(up, Math.sin(tilt))));
|
||||
const back = off; const right = v3.norm(v3.cross(upv, back));
|
||||
return { eye, right, up: v3.cross(back, right), back, T };
|
||||
};
|
||||
function fovFor(w, h, fovMin) { const aspect = w / h; return aspect < 1 ? 2 * Math.atan(Math.tan(fovMin / 2) / aspect) : fovMin; }
|
||||
Cam.view = function (w, h) {
|
||||
let b = Cam.basis(); let fovMin = Cam.fovMin; let range = Cam.range;
|
||||
if (Cam.rig) {
|
||||
const r = Cam.rig.pose();
|
||||
const k = ease.inOut(clamp(Cam.rigBlend, 0, 1));
|
||||
const from = Cam.rigFrom || b;
|
||||
const eye = v3.lerp(from.eye, r.eye, k);
|
||||
const back = v3.norm(v3.lerp(from.back, r.back, k)); let up = v3.norm(v3.lerp(from.up, r.up, k));
|
||||
const right = v3.norm(v3.cross(up, back)); up = v3.cross(back, right);
|
||||
b = { eye, right, up, back };
|
||||
fovMin = lerp(Cam.fovMin, r.fov || Cam.fovMin, k);
|
||||
range = lerp(Cam.range, v3.len(eye) - 1, k);
|
||||
}
|
||||
const aspect = w / h, fovY = fovFor(w, h, fovMin);
|
||||
const alt = Math.max(v3.len(b.eye) - 1, 1e-7);
|
||||
const horizon = Math.sqrt(2 * alt + alt * alt);
|
||||
const near = clamp(alt * 0.03, 1.2e-8, 0.3);
|
||||
const far = Math.max(near * 20, Math.min(12, horizon * 1.3 + 0.004));
|
||||
const [tLat, tLon] = Cam.rig ? geo.ll(b.eye) : [Cam.lat, Cam.lon];
|
||||
return { eye: b.eye, right: b.right, up: b.up, back: b.back, fovY, aspect, near, far, shiftX: Cam.shiftX, shiftY: Cam.shiftY, w, h, range, tLat, tLon };
|
||||
};
|
||||
Cam.ray = function (view, x, y) {
|
||||
const nx = (x / view.w) * 2 - 1, ny = 1 - (y / view.h) * 2;
|
||||
const tY = Math.tan(view.fovY / 2), tX = tY * view.aspect;
|
||||
return v3.norm(v3.add(v3.add(v3.scale(view.right, (nx - view.shiftX) * tX), v3.scale(view.up, (ny - view.shiftY) * tY)), v3.scale(view.back, -1)));
|
||||
};
|
||||
Cam.groundAt = function (view, x, y) {
|
||||
const rd = Cam.ray(view, x, y), ro = view.eye; const b = v3.dot(ro, rd), c = v3.dot(ro, ro) - 1, d = b * b - c;
|
||||
if (d < 0 || b > 0) return null; const t = c / (-b + Math.sqrt(d)); return geo.ll(v3.madd(ro, rd, t));
|
||||
};
|
||||
Cam.snapshot = () => ({ lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftY: Cam.shiftY, shiftX: Cam.shiftX });
|
||||
Cam.set = (s) => { Object.assign(Cam, s); Cam.anim = null; Cam.vel = null; };
|
||||
|
||||
/* ---------- fly-to ---------- */
|
||||
const angDiff = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };
|
||||
Cam.flyTo = function (to, opts = {}) {
|
||||
const from = Cam.snapshot();
|
||||
const tgt = { ...from, ...to };
|
||||
const dist = geo.dist(from.lat, from.lon, tgt.lat, tgt.lon);
|
||||
let dur = opts.duration ?? clamp(1.1 + Math.abs(Math.log(tgt.range / from.range)) * 0.22 + dist * 0.55, 1.0, 3.4);
|
||||
if (prefersReduced() || opts.instant) dur = 0;
|
||||
const peak = Math.max(from.range, tgt.range, dist * 1.25);
|
||||
Cam.vel = null;
|
||||
return new Promise((res) => {
|
||||
if (dur === 0) { Cam.set(tgt); res(); return; }
|
||||
Cam.anim = { from, to: tgt, t: 0, dur, peak, hump: peak > Math.max(from.range, tgt.range) * 1.05, res, va: geo.vec(from.lat, from.lon), vb: geo.vec(tgt.lat, tgt.lon), dh: angDiff(from.heading, tgt.heading) };
|
||||
});
|
||||
};
|
||||
Cam.cancel = () => { if (Cam.anim) { const r = Cam.anim.res; Cam.anim = null; r && r(); } };
|
||||
Cam.update = function (dt) {
|
||||
const A = Cam.anim;
|
||||
if (A) {
|
||||
A.t += dt; const u = clamp(A.t / A.dur, 0, 1); const e = ease.inOut(u);
|
||||
const pe = A.hump ? ease.inOutSine(clamp((u - 0.08) / 0.84, 0, 1)) : e;
|
||||
const p = geo.slerp(A.va, A.vb, pe); const [la, lo] = geo.ll(p);
|
||||
Cam.lat = la; Cam.lon = lo;
|
||||
const l0 = Math.log(A.from.range), l1 = Math.log(A.to.range), lm = Math.log(A.peak);
|
||||
Cam.range = A.hump ? Math.exp((1 - e) * (1 - e) * l0 + 2 * (1 - e) * e * lm + e * e * l1) : Math.exp(lerp(l0, l1, e));
|
||||
Cam.heading = A.from.heading + A.dh * e;
|
||||
const tiltMid = A.hump ? Math.sin(Math.PI * e) : 0;
|
||||
Cam.tilt = lerp(A.from.tilt, A.to.tilt, e) * (1 - tiltMid * 0.85);
|
||||
Cam.shiftY = lerp(A.from.shiftY, A.to.shiftY, e); Cam.shiftX = lerp(A.from.shiftX, A.to.shiftX, e);
|
||||
if (u >= 1) { Cam.anim = null; Object.assign(Cam, { heading: A.to.heading, tilt: A.to.tilt, range: A.to.range, lat: A.to.lat, lon: A.to.lon }); A.res(); }
|
||||
return true;
|
||||
}
|
||||
if (Cam.rig) { Cam.rigBlend = Math.min(1, Cam.rigBlend + dt / (Cam.rig.blendTime || 1.4)); return true; }
|
||||
if (Cam.vel) {
|
||||
const v = Cam.vel; const k = Math.exp(-dt * 3.2);
|
||||
Cam.lat = clamp(Cam.lat + v.lat * dt, -86, 86); Cam.lon = ((Cam.lon + v.lon * dt + 540) % 360) - 180; Cam.heading += v.h * dt;
|
||||
v.lat *= k; v.lon *= k; v.h *= k;
|
||||
if (Math.abs(v.lat) + Math.abs(v.lon) + Math.abs(v.h) < 1e-4) Cam.vel = null;
|
||||
return true;
|
||||
}
|
||||
if (Cam.autoSpin && !Cam.interacting && performance.now() - Cam.lastInput > 2500) { Cam.lon = ((Cam.lon + dt * Cam.autoSpin + 540) % 360) - 180; return true; }
|
||||
return false;
|
||||
};
|
||||
Cam.setRig = function (rig) { // rig.pose() → {eye, back, up, fov}
|
||||
Cam.rigFrom = rig ? (Cam.rig ? Cam.view(400, 800) : Cam.basis()) : null;
|
||||
if (rig) { Cam.anim = null; Cam.vel = null; }
|
||||
Cam.rig = rig; Cam.rigBlend = prefersReduced() ? 1 : 0;
|
||||
};
|
||||
// pose helper: eye at lat/lon/alt, looking along heading/pitch (rad), optional roll
|
||||
Cam.pose = function (lat, lon, altKm, hdg, pitch, roll = 0, fov) {
|
||||
const eye = geo.vec(lat, lon, altKm); const { east, north, up } = geo.enu(lat, lon);
|
||||
const F0 = v3.add(v3.scale(north, Math.cos(hdg)), v3.scale(east, Math.sin(hdg)));
|
||||
const fwd = v3.norm(v3.add(v3.scale(F0, Math.cos(pitch)), v3.scale(up, Math.sin(pitch))));
|
||||
let upv = v3.norm(v3.sub(up, v3.scale(fwd, v3.dot(up, fwd))));
|
||||
let right = v3.norm(v3.cross(fwd, upv));
|
||||
if (roll) { const c = Math.cos(roll), s = Math.sin(roll); const u2 = v3.add(v3.scale(upv, c), v3.scale(right, s)); right = v3.norm(v3.cross(fwd, u2)); upv = u2; }
|
||||
return { eye, back: v3.scale(fwd, -1), up: upv, fov };
|
||||
};
|
||||
|
||||
/* ---------- input ---------- */
|
||||
Cam.attach = function (el, getView) {
|
||||
const pts = new Map(); let last = null, moved = 0, downAt = 0, samples = [];
|
||||
const metersPx = (view) => (Cam.range * 2 * Math.tan(view.fovY / 2)) / view.h * (1 + Cam.range * 0.18);
|
||||
const panBy = (dx, dy, view) => {
|
||||
const k = metersPx(view);
|
||||
const ch = Math.cos(Cam.heading), sh = Math.sin(Cam.heading);
|
||||
const tiltK = 1 + Math.min(Cam.tilt, Cam.maxTilt(Cam.range)) * 0.8;
|
||||
const de = (-dx * ch + dy * sh * tiltK) * k, dn = (dx * sh + dy * ch * tiltK) * k;
|
||||
const dLat = dn * R2D, dLon = (de * R2D) / Math.max(0.15, Math.cos(Cam.lat * D2R));
|
||||
Cam.lat = clamp(Cam.lat + dLat, -86, 86); Cam.lon = ((Cam.lon + dLon + 540) % 360) - 180;
|
||||
return [dLat, dLon];
|
||||
};
|
||||
const zoomAt = (ratio, x, y, view) => {
|
||||
const g = Cam.groundAt(view, x, y);
|
||||
const nr = clamp(Cam.range * ratio, Cam.minRange, Cam.maxRange); const f = 1 - nr / Cam.range;
|
||||
if (g && f > 0) { const p = geo.slerp(geo.vec(Cam.lat, Cam.lon), geo.vec(g[0], g[1]), f * 0.92); const [la, lo] = geo.ll(p); Cam.lat = la; Cam.lon = lo; }
|
||||
Cam.range = nr;
|
||||
};
|
||||
el.addEventListener('pointerdown', (e) => {
|
||||
if (!Cam.enabled) return;
|
||||
el.setPointerCapture?.(e.pointerId);
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY, b: e.button });
|
||||
Cam.cancel(); Cam.vel = null; Cam.interacting = true; Cam.lastInput = performance.now();
|
||||
if (pts.size === 1) { moved = 0; downAt = performance.now(); samples = []; }
|
||||
last = null; bus.emit('globe-touch');
|
||||
});
|
||||
el.addEventListener('pointermove', (e) => {
|
||||
if (!pts.has(e.pointerId) || !Cam.enabled) return;
|
||||
const view = getView(); const p = pts.get(e.pointerId); const nx = e.clientX, ny = e.clientY;
|
||||
const dx = nx - p.x, dy = ny - p.y;
|
||||
if (pts.size === 1) {
|
||||
moved += Math.abs(dx) + Math.abs(dy);
|
||||
if (Cam.rig && Cam.rig.look) { Cam.rig.look(dx, dy); }
|
||||
else if (p.b === 2 || e.shiftKey || e.ctrlKey) { Cam.heading -= dx * 0.006; Cam.tilt = clamp(Cam.tilt + dy * 0.005, 0, Cam.maxTilt(Cam.range)); }
|
||||
else { const [dl, dn] = panBy(dx, dy, view); samples.push({ t: performance.now(), dl, dn }); }
|
||||
p.x = nx; p.y = ny;
|
||||
} else if (pts.size === 2) {
|
||||
const ids = [...pts.keys()]; const a = pts.get(ids[0]), b = pts.get(ids[1]);
|
||||
const before = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
|
||||
p.x = nx; p.y = ny;
|
||||
const after = { d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
|
||||
const ratio = before.d / Math.max(after.d, 1);
|
||||
const mdx = after.mx - before.mx, mdy = after.my - before.my;
|
||||
if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(ratio); }
|
||||
else {
|
||||
if (Math.abs(ratio - 1) < 0.006 && Math.abs(mdy) > Math.abs(mdx) * 1.4) Cam.tilt = clamp(Cam.tilt - mdy * 0.006, 0, Cam.maxTilt(Cam.range));
|
||||
else { zoomAt(ratio, after.mx, after.my, view); panBy(mdx, mdy, view); Cam.heading += angDiff(before.ang, after.ang) * -1; }
|
||||
}
|
||||
moved += 99;
|
||||
}
|
||||
Cam.lastInput = performance.now();
|
||||
bus.emit('camera-move');
|
||||
});
|
||||
const up = (e) => {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
const wasSingle = pts.size === 1; pts.delete(e.pointerId);
|
||||
if (wasSingle) {
|
||||
Cam.interacting = false;
|
||||
const dtp = performance.now() - downAt;
|
||||
if (moved < 9 && dtp < 450) { const r = el.getBoundingClientRect(); bus.emit('tap', { x: e.clientX - r.left, y: e.clientY - r.top }); }
|
||||
else if (!Cam.rig) {
|
||||
const now = performance.now(); const rec = samples.filter((s) => now - s.t < 90);
|
||||
if (rec.length > 1) { const span = (rec[rec.length - 1].t - rec[0].t) / 1000 || 0.016; const sl = rec.reduce((s, x) => s + x.dl, 0) / span, sn = rec.reduce((s, x) => s + x.dn, 0) / span; Cam.vel = { lat: clamp(sl, -90, 90), lon: clamp(sn, -180, 180), h: 0 }; if (prefersReduced()) Cam.vel = null; }
|
||||
}
|
||||
}
|
||||
};
|
||||
el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
|
||||
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
el.addEventListener('wheel', (e) => {
|
||||
if (!Cam.enabled) return; e.preventDefault(); Cam.cancel(); Cam.vel = null; Cam.lastInput = performance.now();
|
||||
if (Cam.rig && Cam.rig.zoom) { Cam.rig.zoom(Math.exp(e.deltaY * 0.0015)); return; }
|
||||
const r = el.getBoundingClientRect(); zoomAt(Math.exp(e.deltaY * 0.0016), e.clientX - r.left, e.clientY - r.top, getView()); bus.emit('camera-move');
|
||||
}, { passive: false });
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/* Situations — the feed's genres (DESIGN.md §9). Live metrics are computed from the synthetic world;
|
||||
names, handles, posts and reconstruction claims are illustrative. */
|
||||
const SIT = (GE.SIT = {});
|
||||
const fam = (f) => GE.FAM[f].color;
|
||||
const RSit = rng(4242);
|
||||
function walk(n, start, vol, drift = 0, seed = 1) { const r = rng(seed); let v = start; const out = []; for (let i = 0; i < n; i++) { v += r.gauss() * vol + drift; out.push(v); } return out; }
|
||||
|
||||
/* user locale ("Near you") from time zone only — no geolocation */
|
||||
const TZMAP = { 'America/Los_Angeles': ['San Francisco', 37.77, -122.42], 'America/New_York': ['New York', 40.71, -74.0], 'America/Chicago': ['Chicago', 41.88, -87.63], 'America/Denver': ['Denver', 39.74, -104.99], 'Europe/London': ['London', 51.51, -0.13], 'Europe/Paris': ['Paris', 48.86, 2.35], 'Europe/Berlin': ['Berlin', 52.52, 13.4], 'Asia/Tokyo': ['Tokyo', 35.68, 139.69], 'Asia/Kolkata': ['Delhi', 28.61, 77.21], 'Asia/Kathmandu': ['Kathmandu', 27.72, 85.32], 'Australia/Sydney': ['Sydney', -33.87, 151.21], 'Asia/Singapore': ['Singapore', 1.35, 103.82] };
|
||||
let tz = 'UTC'; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch {}
|
||||
const ME = TZMAP[tz] || ['London', 51.51, -0.13];
|
||||
GE.ME = { name: ME[0], lat: ME[1], lon: ME[2], tz };
|
||||
|
||||
/* ISS pass prediction over "you" on the synthetic orbit */
|
||||
function issPass(fromMs) {
|
||||
const obs = geo.vec(GE.ME.lat, GE.ME.lon); const up = v3.norm(obs);
|
||||
let best = null, inPass = false, cur = null;
|
||||
for (let s = 0; s < 36 * 3600; s += 20) {
|
||||
const ms = fromMs + s * 1000, t = (ms - NOW) / 1000; const g = astro.sun(ms).gmst;
|
||||
const p = GE.satAt(GE.ISS, t, g); const d = v3.norm(v3.sub(p, obs)); const el = Math.asin(v3.dot(d, up)) * R2D;
|
||||
if (el > 10) { if (!inPass) { inPass = true; cur = { start: ms, max: el, maxAt: ms }; } if (el > cur.max) { cur.max = el; cur.maxAt = ms; } }
|
||||
else if (inPass) { inPass = false; cur.end = ms; if (!best && cur.max > 18) { best = cur; break; } }
|
||||
}
|
||||
return best || { start: fromMs + 3.2 * 3600000, max: 41, maxAt: fromMs + 3.25 * 3600000, end: fromMs + 3.3 * 3600000 };
|
||||
}
|
||||
function busiestAirport() {
|
||||
const t = GE.world.t; const counts = new Map();
|
||||
for (const f of GE.FLIGHTS) { if (f.kind !== 0) continue; const p = GE.flightAt(f, t); if (!p) continue; for (const ap of [p.from, p.to]) { if (!ap.iata || ap.iata.length !== 3) continue; const d = geo.dist(p.lat, p.lon, ap.lat, ap.lon) * EARTH_KM; if (d < 160) counts.set(ap.iata, (counts.get(ap.iata) || 0) + 1); } }
|
||||
let best = 'ATL', bc = 0; for (const [k, v] of counts) if (v > bc) { bc = v; best = k; }
|
||||
return { iata: best, ap: GE.apByIata[best], n: bc, perH: Math.round(bc * 5.6) };
|
||||
}
|
||||
SIT.issPass = issPass; SIT.busiest = busiestAirport;
|
||||
// daytime replay helper: if it is night at a place now, use the most recent local afternoon
|
||||
function dayReplay(lat, lon, localHour = 15.5) {
|
||||
if (astro.elev(NOW, lat, lon) > 8) return null;
|
||||
const tzh = Math.round(lon / 15); const d = new Date(NOW); let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0) + (localHour - tzh) * 3600000;
|
||||
while (t > NOW) t -= 86400000; return { at: t, start: t - 3 * 3600000, end: t + 3 * 3600000, replay: true };
|
||||
}
|
||||
SIT.dayReplay = dayReplay;
|
||||
const LAUNCH_T0 = NOW + (2 * 3600 + 14 * 60 + 33) * 1000;
|
||||
const KOSHI_T = GE.scenes.FLOOD.startMs;
|
||||
const AURORA_T = (() => { const d = new Date(NOW); d.setUTCHours(21, 30, 0, 0); if (d.getTime() < NOW) d.setUTCDate(d.getUTCDate() + 1); return d.getTime(); })();
|
||||
|
||||
/* ---------- situation definitions ---------- */
|
||||
const S = [];
|
||||
const add = (o) => { o.updatedAt = o.updatedAt || NOW - (o.ageS || 60) * 1000; S.push(o); return o; };
|
||||
add({
|
||||
id: 'cyclone', short: 'Typhoon Halcyon', fam: 'haz', kind: 'cyclone', section: 'now', live: true, aspect: 1.25, ageS: 120, watchers: 48200,
|
||||
title: 'Typhoon Halcyon tracks toward Luzon Strait', place: 'Philippine Sea', lat: 16.2, lon: 128.8,
|
||||
camera: { lat: 15.2, lon: 127.6, range: 0.42, heading: -0.25, tilt: 0.42 }, thumbCam: { lat: 16.6, lon: 128.4, range: 0.3, heading: -0.2, tilt: 0.36 },
|
||||
layers: ['cyclones', 'radar', 'lightning', 'clouds', 'models', 'ais', 'flights'], source: 'Advisory feed (illustrative)',
|
||||
metric: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [`${Math.round(c.wind)}<small>km/h</small>`, `${Math.round(c.p)} hPa · moving WNW 19 km/h`]; },
|
||||
metrics: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return [{ v: Math.round(c.wind), u: 'km/h', l: 'Max sustained', s: 'Advisory · 2 min' }, { v: Math.round(c.p), u: 'hPa', l: 'Central pressure', s: 'Advisory · 2 min' }, { v: 610, u: 'km', l: 'To Luzon Strait', s: 'Computed · now' }]; },
|
||||
accessory: () => { const c = GE.CYCLONE.at(GE.world.offsetH); return `${Math.round(c.wind)} km/h · ${Math.round(c.p)} hPa · updated 2 min ago`; },
|
||||
brief: {
|
||||
lede: 'A compact, intense typhoon is moving west-northwest at 19 km/h. The forecast cone widens to ±370 km by day 5; most guidance recurves it north of Luzon before a weakening landfall near Taiwan. Ships are routing south of the track; two vessels are holding off Batanes.',
|
||||
series: [
|
||||
{ label: 'Max wind', unit: 'km/h', data: GE.CYCLONE.track.map((x) => x[3]), split: 6, band: 18, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v) },
|
||||
{ label: 'Pressure', unit: 'hPa', data: GE.CYCLONE.track.map((x) => x[4]), split: 6, band: 6, src: 'Advisory feed · 6-hourly · 2 min', fmt: (v) => Math.round(v), invert: true },
|
||||
{ label: 'Ships within 500 km', unit: '', data: walk(24, 40, 2, -0.6, 3).map((v) => Math.max(0, Math.round(v))), src: 'AIS · 5 min bins · 40 s', fmt: (v) => v },
|
||||
],
|
||||
multiples: { kind: 'globe', label: 'Track, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? '<b>Now</b>' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h' })) },
|
||||
sources: [['Tropical cyclone advisory (illustrative)', '2 min', 'live'], ['Geostationary IR mosaic', '10 min', 'live'], ['AIS vessel positions', '40 s', 'live'], ['Ensemble forecast tracks (14 members)', '3 h', 'stale']],
|
||||
},
|
||||
});
|
||||
add({
|
||||
id: 'quake', short: 'M 6.4 · Honshu', fam: 'haz', kind: 'quake', section: 'now', live: true, aspect: 1.0, ageS: 5.4 * 3600, watchers: 21900,
|
||||
title: 'M 6.4 off the coast of Honshu — largest in 24 h', place: 'Off eastern Honshu, Japan', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon,
|
||||
camera: { lat: 35.2, lon: 141.0, range: 0.13, heading: 0.35, tilt: 0.75 }, layers: ['quakes', 'flights', 'ais', 'cctv'], source: 'Seismic network (illustrative)',
|
||||
metric: () => [`M 6.4`, `32 km deep · 9 aftershocks`], metrics: () => [{ v: '6.4', u: 'Mw', l: 'Magnitude', s: 'Seismic net · 5 h' }, { v: 32, u: 'km', l: 'Depth', s: 'Seismic net · 5 h' }, { v: 9, u: '', l: 'Aftershocks ≥ M3.4', s: 'Seismic net · 12 min' }],
|
||||
brief: {
|
||||
lede: 'A shallow thrust event under the Pacific margin, felt widely along the coast. No tsunami was generated beyond 20 cm. Rail services paused for inspection and resumed within 90 minutes; flights into HND were briefly held.',
|
||||
series: [{ label: 'Aftershocks per hour', unit: '', data: [0, 0, 0, 0, 0, 7, 5, 3, 3, 2, 2, 1, 1, 2, 1, 0, 1, 1], src: 'Seismic catalog · hourly · 12 min', fmt: (v) => v }, { label: 'Coastal tide gauge', unit: 'cm', data: walk(36, 0, 1.2, 0, 7).map((v, i) => (i > 18 && i < 26 ? v + 14 * Math.sin((i - 18) / 8 * Math.PI) : v)), band: 3, src: 'Tide gauge (illustrative) · 1 min · 3 min', fmt: (v) => v.toFixed(0) }],
|
||||
multiples: { kind: 'bars', label: 'Felt reports by prefecture', items: [['Fukushima', 0.9], ['Ibaraki', 0.75], ['Miyagi', 0.6], ['Chiba', 0.45]] },
|
||||
sources: [['Seismic network catalog (illustrative)', '12 min', 'live'], ['Tide gauges', '3 min', 'live'], ['Rail status boards', '9 min', 'live']],
|
||||
},
|
||||
});
|
||||
add({
|
||||
id: 'fires', short: 'Fires · Iberia', fam: 'haz', kind: 'fires', section: 'now', live: true, aspect: 1.33, ageS: 38 * 60, watchers: 9800,
|
||||
title: 'Wildfire clusters across Iberia and Greece', place: 'Southern Europe', lat: 40.0, lon: -6.5,
|
||||
camera: { lat: 39.2, lon: -6.0, range: 0.18, heading: 0.1, tilt: 0.55 }, layers: ['fires', 'perims', 'aq', 'wind', 'flights'], source: 'VIIRS 375 m hotspots',
|
||||
metric: () => [`${fmt.n(GE.FIRES.filter((f) => f.t <= GE.world.t && (f.cluster === 'Central Portugal' || f.cluster === 'Galicia' || f.cluster === 'Attica' || f.cluster === 'Epirus' || f.cluster === 'Antalya')).length)}<small>hotspots</small>`, 'Portugal · Galicia · Attica · Antalya'],
|
||||
metrics: () => [{ v: GE.FIRES.filter((f) => f.t <= GE.world.t && f.lat > 34 && f.lon > -10 && f.lon < 32).length, u: '', l: 'Active hotspots', s: 'VIIRS · 38 min' }, { v: '3.4k', u: 'ha', l: 'Mapped perimeter', s: 'Perimeters · 3 h' }, { v: 31, u: 'km/h', l: 'Wind, Galicia', s: 'Model · 1 h' }],
|
||||
brief: { lede: 'Dry easterly winds are pushing fire fronts toward the Atlantic in central Portugal and Galicia. Smoke is degrading air quality in Porto; the Attica cluster is contained on its north flank.',
|
||||
series: [{ label: 'Hotspots, 6 h bins', unit: '', data: [12, 18, 44, 61, 58, 80, 102, 96, 120, 140, 133, 151], src: 'VIIRS / MODIS · 6 h · 38 min', fmt: (v) => v }, { label: 'PM2.5 Porto', unit: 'µg/m³', data: walk(30, 14, 3, 1.4, 11), band: 8, src: 'Air-quality sensors · 1 h · 22 min', fmt: (v) => Math.round(v) }],
|
||||
multiples: { kind: 'globe', label: 'Spread, 24 h apart', items: [-48, -24, 0, 24].map((h) => ({ h, cap: h === 0 ? '<b>Now</b>' : (h < 0 ? '−' : '+') + Math.abs(h) + ' h', cam: { lat: 40.6, lon: -7.8, range: 0.05, heading: 0, tilt: 0.3 } })) },
|
||||
sources: [['VIIRS 375 m active fires', '38 min', 'live'], ['Fire perimeters (illustrative)', '3 h', 'stale'], ['Wind model', '1 h', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'milair', short: 'Military air · Baltic', fam: 'sky', kind: 'mil', section: 'now', live: true, aspect: 1.0, ageS: 30, watchers: 17300,
|
||||
title: 'Military air activity over the Baltic', place: 'Baltic Sea', lat: 56.5, lon: 19.5,
|
||||
camera: { lat: 56.0, lon: 19.0, range: 0.16, heading: 0.0, tilt: 0.5 }, layers: ['military', 'flights', 'gnss', 'holding'], source: 'ADS-B / MLAT',
|
||||
metric: () => [`38<small>aircraft</small>`, '3 tankers holding · GNSS jamming'], metrics: () => [{ v: 38, u: '', l: 'Military aircraft', s: 'ADS-B/MLAT · 30 s' }, { v: 3, u: '', l: 'Tankers in orbit', s: 'ADS-B · 30 s' }, { v: '41', u: '%', l: 'Flights w/ GNSS loss', s: 'NIC/NACp · 10 min' }],
|
||||
brief: { lede: 'Two tanker orbits are active over southern Sweden with fighter pairs cycling. GNSS integrity reports from airliners show a jamming footprint centred off Kaliningrad, affecting 41% of overflights in the last hour.',
|
||||
series: [{ label: 'Military aircraft aloft', unit: '', data: walk(36, 26, 2, 0.35, 21).map(Math.round), src: 'ADS-B / MLAT · 10 min · 30 s', fmt: (v) => v }, { label: 'GNSS-degraded flights', unit: '%', data: walk(36, 30, 2.4, 0.3, 23), band: 6, src: 'Aircraft integrity reports · 10 min', fmt: (v) => Math.round(v) }],
|
||||
multiples: { kind: 'bars', label: 'By type', items: [['Fighters', 0.8], ['Tankers', 0.22], ['ISR', 0.3], ['Transport', 0.5]] },
|
||||
sources: [['ADS-B / MLAT network', '30 s', 'live'], ['GNSS integrity (NIC/NACp)', '10 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'iss', fam: 'orbit', kind: 'iss', section: 'near', live: true, aspect: 1.33, ageS: 4, watchers: 6400,
|
||||
title: `ISS pass over ${GE.ME.name}`, place: `${GE.ME.name} (from your time zone)`, lat: GE.ME.lat, lon: GE.ME.lon,
|
||||
camera: { lat: GE.ME.lat, lon: GE.ME.lon, range: 0.55, heading: 0, tilt: 0.55 }, layers: ['crewed', 'tracks', 'sats', 'trains'], source: 'TLE propagation',
|
||||
dyn: true,
|
||||
metric: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); const s = (p.start - Date.now()) / 1000; return [s > 0 ? `in ${Math.floor(s / 3600)}<small>h</small> ${Math.floor((s % 3600) / 60)}<small>m</small>` : 'now', `max ${Math.round(p.max)}° · ${fmt.hm(p.maxAt)} UTC`]; },
|
||||
metrics: () => { const p = SIT._iss || (SIT._iss = issPass(Date.now())); return [{ v: fmt.hm(p.start), u: 'UTC', l: 'Rises', s: 'TLE · 4 s' }, { v: Math.round(p.max), u: '°', l: 'Max elevation', s: 'Computed' }, { v: Math.round((p.end - p.start) / 60000), u: 'min', l: 'Visible for', s: 'Computed' }]; },
|
||||
brief: { lede: 'The station will rise in the west-southwest and climb high across your sky. If the sky is clear and the pass is after dusk it will be the brightest point-like object, brighter than any star.', series: [{ label: 'Elevation during pass', unit: '°', data: Array.from({ length: 30 }, (_, i) => Math.max(0, Math.sin((i / 29) * Math.PI)) * 54), src: 'Propagated from TLE · computed', fmt: (v) => Math.round(v) }], sources: [['Two-line elements (illustrative epoch)', '4 h', 'live'], ['Sun/terminator', 'computed', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'airport', short: 'Busiest: ATL', fam: 'sky', kind: 'airport', section: 'always', live: true, aspect: 1.25, ageS: 20, watchers: 5200,
|
||||
title: 'Busiest airport right now', place: 'Hartsfield–Jackson Atlanta', lat: 33.6407, lon: -84.4277,
|
||||
camera: { lat: 33.636, lon: -84.4277, range: 0.0042, heading: 0.35, tilt: 1.05 }, layers: ['flights', 'airports', 'holding', 'traffic'], source: 'ADS-B',
|
||||
metric: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [`${b.iata} <small>${b.perH} mvts/h</small>`, `${b.ap.city} · ${b.n} aircraft within 160 km`]; },
|
||||
metrics: () => { const b = SIT._busy || (SIT._busy = busiestAirport()); return [{ v: b.perH, u: '/h', l: 'Movements', s: 'ADS-B · 20 s' }, { v: b.n, u: '', l: 'Aircraft < 160 km', s: 'ADS-B · 20 s' }, { v: 4, u: 'min', l: 'Avg. taxi-out delay', s: 'Derived · 15 min' }]; },
|
||||
brief: { lede: 'Arrivals are landing on the two southern runways in a steady stream; departures are pushing west. The evening bank peaks in 40 minutes.', series: [{ label: 'Movements per hour', unit: '', data: [62, 70, 88, 94, 101, 97, 90, 85, 92, 104, 112, 108], src: 'ADS-B · hourly · 20 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'Top 4 right now', items: [['ATL', 0.95], ['DFW', 0.84], ['DXB', 0.8], ['LHR', 0.74]] }, sources: [['ADS-B network', '20 s', 'live'], ['Airport board (scraped, illustrative)', '2 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'novosel', short: 'Fort Novosel circuits', fam: 'sky', kind: 'heli', section: 'always', live: true, aspect: 1.5, ageS: 15, watchers: 3100, ride: true,
|
||||
title: 'Fort Novosel helicopter school — jump into the chopper', place: 'Cairns Army Airfield, Alabama', lat: 31.276, lon: -85.713,
|
||||
camera: { lat: 31.268, lon: -85.705, range: 0.0019, heading: -0.6, tilt: 1.12 }, layers: ['helis', 'flights', 'ga', 'traffic'], source: 'ADS-B / MLAT', time: dayReplay(31.276, -85.713, 15.2),
|
||||
metric: () => [`${GE.HELI_CIRCUITS.filter((h) => h.field).length}<small>in circuits</small>`, 'Cairns + 3 stagefields · 500 ft AGL'],
|
||||
metrics: () => [{ v: GE.HELI_CIRCUITS.filter((h) => h.field).length, u: '', l: 'Helicopters in circuits', s: 'ADS-B · 15 s' }, { v: 500, u: 'ft', l: 'Pattern altitude', s: 'Observed' }, { v: 5.5, u: 'min', l: 'Circuit time', s: 'Derived' }],
|
||||
brief: { lede: 'Student pilots are flying rectangular traffic patterns at Cairns and three stagefields: upwind, crosswind, downwind, base, final — over and over. Tap Ride to sit in the left seat of one of them.', series: [{ label: 'Helicopters aloft', unit: '', data: [2, 4, 9, 14, 17, 19, 19, 18, 19, 17, 19, 19], src: 'ADS-B / MLAT · hourly · 15 s', fmt: (v) => v }, { label: 'Groundspeed, HAWK 21', unit: 'kt', data: Array.from({ length: 40 }, (_, i) => 70 + 12 * Math.sin(i / 3.2) + (i % 10 > 7 ? -30 : 0)), src: 'ADS-B · 1 s · 15 s', fmt: (v) => Math.round(v) }], sources: [['ADS-B / MLAT', '15 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'launch', short: 'Launch · SLC-40', fam: 'orbit', kind: 'launch', section: 'now', live: true, aspect: 1.5, ageS: 300, watchers: 33800,
|
||||
title: 'Rideshare launch from SLC-40 — countdown', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
|
||||
camera: { lat: 28.552, lon: -80.590, range: 0.0011, heading: 0.75, tilt: 1.2 }, layers: ['launches', 'flights', 'ais', 'tracks'], source: 'Range schedule (illustrative)',
|
||||
metric: () => [`<span class="num">T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}</span>`, 'Rideshare to SSO · trajectory NE'],
|
||||
metrics: () => [{ v: fmt.dur((LAUNCH_T0 - Date.now()) / 1000), u: '', l: 'Countdown', s: 'Range · 5 min' }, { v: 92, u: '%', l: 'Weather go', s: 'Forecast · 1 h' }, { v: 14, u: '', l: 'Vessels in hazard area', s: 'AIS · 40 s' }],
|
||||
brief: { lede: 'Vehicle is vertical on the pad. The hazard area offshore is still being cleared — 14 vessels remain inside. Trajectory heads northeast along the coast.', series: [{ label: 'Vessels in hazard area', unit: '', data: [41, 39, 36, 33, 31, 28, 24, 21, 19, 17, 15, 14], src: 'AIS · 15 min · 40 s', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['AIS', '40 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'koshi', short: 'Bhote Koshi · 4D', fam: 'haz', kind: 'recon', section: 'recon', live: false, aspect: 1.33, ageS: 53 * 86400, watchers: 12800, thread: 'koshi', d4: true,
|
||||
title: 'Bhote Koshi outburst flood — 4D reconstruction', place: 'Rasuwa, Nepal · 26 Aug 2026', lat: 28.215, lon: 85.36,
|
||||
camera: { lat: 28.212, lon: 85.352, range: 0.0036, heading: 0.35, tilt: 1.08 }, time: { at: KOSHI_T + 2.2 * 3600000, start: KOSHI_T - 3 * 3600000, end: KOSHI_T + 9 * 3600000 },
|
||||
layers: ['signed', 'holo', 'floods', 'helis'], source: 'Thread · 23 observations',
|
||||
metric: () => ['23<small>obs</small> · 9<small>hyp</small>', '14 people · 3 AI proposals'], metrics: () => [{ v: 23, u: '', l: 'Observations', s: 'Ledger · 2 h' }, { v: 9, u: '', l: 'Hypotheses', s: 'Ledger · 2 h' }, { v: 14, u: '', l: 'Contributors', s: 'Thread' }],
|
||||
brief: { lede: 'An outburst flood was observed on the Bhote Koshi on 26 August 2026. This thread reconstructs the crest’s passage from Rasuwagadhi down to Syaphrubesi using signed witness captures, river-gauge readings and satellite imagery. Witness posts are illustrative.', series: [{ label: 'River stage, Syaphrubesi (illustrative)', unit: 'm', data: Array.from({ length: 40 }, (_, i) => 3.1 + 4.7 * Math.exp(-Math.pow((i - 16) / 5, 2)) + (i > 16 ? 0.6 : 0)), band: 0.4, src: 'Gauge (illustrative) · 15 min', fmt: (v) => v.toFixed(1) }], sources: [['Signed witness captures (illustrative)', '53 d', 'stale'], ['Optical satellite tiles, before/after', '50 d', 'stale']] },
|
||||
});
|
||||
add({
|
||||
id: 'concert', short: 'Stadium encore · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.25, ageS: 20 * 3600, watchers: 88200, thread: 'concert', d4: true,
|
||||
title: 'Stadium encore in 4D — from 2,311 crowd captures', place: 'Wembley, London · last night', lat: 51.556, lon: -0.2796,
|
||||
camera: { lat: 51.5552, lon: -0.2810, range: 0.00034, heading: 0.55, tilt: 1.12 }, time: { at: NOW - 20 * 3600000 + 0.0, start: NOW - 22 * 3600000, end: NOW - 19 * 3600000 },
|
||||
layers: ['signed', 'holo', 'venues', 'cctv'], source: 'Thread · 2,311 signed captures', nightThumb: true,
|
||||
metric: () => ['2,311<small>captures</small>', '4D · 41 min · 86 seat blocks'], metrics: () => [{ v: '2,311', u: '', l: 'Signed captures', s: 'Ledger · 20 h' }, { v: 86, u: '', l: 'Seat blocks covered', s: 'Derived' }, { v: '±9', u: 'ms', l: 'Time alignment', s: 'Audio sync' }],
|
||||
brief: { lede: 'A crowd-sourced reconstruction of last night’s encore: 2,311 signed phone captures aligned by audio fingerprint and IMU traces into a single 4D scene you can scrub. Performer and attendee identities are not part of the record.', series: [{ label: 'Captures per minute', unit: '', data: [3, 5, 9, 21, 64, 140, 188, 162, 120, 96, 72, 40], src: 'Signed bundles · 1 min', fmt: (v) => v }], sources: [['Signed crowd captures (illustrative)', '20 h', 'stale']] },
|
||||
});
|
||||
add({
|
||||
id: 'climb', short: 'Tower climb · 4D', fam: 'eyes', kind: 'recon', section: 'recon', live: false, aspect: 1.5, ageS: 9 * 3600, watchers: 41500, thread: 'climb', d4: true,
|
||||
title: 'Free-climber on a Midtown tower — reconstructed', place: 'Midtown Manhattan · this morning', lat: 40.7553, lon: -73.9838,
|
||||
camera: { lat: 40.7528, lon: -73.9870, range: 0.00027, heading: 0.62, tilt: 1.2 }, time: { at: NOW - 9 * 3600000, start: NOW - 11 * 3600000, end: NOW - 7 * 3600000 },
|
||||
layers: ['helis', 'signed', 'holo', 'cctv'], source: 'Thread · 3 news helicopters (ADS-B)',
|
||||
metric: () => ['41<small>clips</small> · 3<small>helis</small>', 'Fictional tower · ascent 2 h 14 m'], metrics: () => [{ v: 41, u: '', l: 'Witness clips', s: 'Ledger · 9 h' }, { v: 3, u: '', l: 'News helicopters', s: 'ADS-B · 9 h' }, { v: 412, u: 'm', l: 'Tower height', s: 'Model (fictional tower)' }],
|
||||
brief: { lede: 'Three news helicopters orbited the tower for two hours; their ADS-B tracks give precise camera positions for every broadcast frame. Combined with 41 signed street-level clips, the climber’s height over time is reconstructed to ±3 m. The tower and people are fictional.', series: [{ label: 'Climber height (reconstructed)', unit: 'm', data: Array.from({ length: 30 }, (_, i) => Math.min(380, i * 14 + Math.sin(i) * 6)), band: 3, src: 'Hypothesis H2 · ±3 m', fmt: (v) => Math.round(v) }], sources: [['ADS-B: 3 helicopters', '9 h', 'stale'], ['Signed street clips (illustrative)', '9 h', 'stale']] },
|
||||
});
|
||||
add({
|
||||
id: 'suez', short: 'Suez queue', fam: 'sea', kind: 'queue', section: 'always', live: true, aspect: 1.0, ageS: 40, watchers: 7700,
|
||||
title: 'Ships queuing at Suez', place: 'Gulf of Suez & Port Said anchorages', lat: 30.3, lon: 32.4,
|
||||
camera: { lat: 29.75, lon: 32.45, range: 0.045, heading: 0.2, tilt: 0.85 }, layers: ['ais', 'tankers', 'ports'], source: 'AIS',
|
||||
metric: () => [`${GE.SHIPS.filter((s) => s.queue === 'suez').length}<small>waiting</small>`, 'avg wait 31 h · southbound convoy 06:00'],
|
||||
metrics: () => [{ v: GE.SHIPS.filter((s) => s.queue === 'suez').length, u: '', l: 'Ships at anchor', s: 'AIS · 40 s' }, { v: 31, u: 'h', l: 'Average wait', s: 'Derived · 1 h' }, { v: 52, u: '', l: 'Transits / 24 h', s: 'AIS · 1 h' }],
|
||||
brief: { lede: 'The anchorage off Suez is fuller than its 30-day median; northbound tankers are waiting longest.', series: [{ label: 'Ships at anchor', unit: '', data: walk(30, 52, 2.2, 0.75, 31).map(Math.round), band: 5, src: 'AIS · 6 h · 40 s', fmt: (v) => v }], multiples: { kind: 'bars', label: 'By type', items: [['Tanker', 0.82], ['Bulk', 0.6], ['Container', 0.45], ['LNG', 0.22]] }, sources: [['AIS terrestrial + satellite', '40 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'aurora', short: 'Aurora · Kp 6', fam: 'wx', kind: 'aurora', section: 'near', live: true, aspect: 1.33, ageS: 600, watchers: 15100,
|
||||
title: 'Aurora nowcast — Kp 6 expected tonight', place: 'Northern Scandinavia & Iceland', lat: 69.6, lon: 18.9,
|
||||
camera: { lat: 62.5, lon: 12.0, range: 0.62, heading: 0.15, tilt: 0.95 }, time: { at: AURORA_T }, layers: ['clouds', 'signed', 'holo', 'flights'], source: 'Space-weather forecast', auroraK: 1.15,
|
||||
metric: () => ['Kp 6<small>tonight</small>', 'Tromsø 92% · cloud 18%'], metrics: () => [{ v: 6, u: 'Kp', l: 'Forecast peak', s: 'Space weather · 10 min' }, { v: 92, u: '%', l: 'Chance, Tromsø', s: 'Model · 10 min' }, { v: 18, u: '%', l: 'Cloud cover', s: 'Satellite · 15 min' }],
|
||||
brief: { lede: 'A coronal mass ejection is arriving earlier than forecast. The auroral oval will push south after dark; sky captures from Tromsø already show a bright arc.', series: [{ label: 'Kp forecast', unit: '', data: [2, 2, 3, 3, 4, 5, 6, 6, 5, 4, 4, 3], band: 1, src: 'Space-weather forecast · 3 h · 10 min', fmt: (v) => v }], sources: [['Space-weather forecast', '10 min', 'live'], ['Sky captures (illustrative)', '18 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'drones', short: 'Drone corridor', fam: 'ground', kind: 'drone', section: 'always', live: false, aspect: 1.25, ageS: 3600, watchers: 2300,
|
||||
title: 'Delivery-drone corridor — today’s replay', place: 'Suburban Texas (illustrative operator)', lat: 30.588, lon: -96.29,
|
||||
camera: { lat: 30.578, lon: -96.300, range: 0.0013, heading: 0.5, tilt: 1.1 }, layers: ['drones', 'traffic'], source: 'Remote ID broadcasts', time: dayReplay(30.588, -96.29, 14.5),
|
||||
metric: () => ['212<small>drops</small>', '16 drones · 4.1 min median'], metrics: () => [{ v: 212, u: '', l: 'Deliveries today', s: 'Remote ID · 1 h' }, { v: 16, u: '', l: 'Drones active', s: 'Remote ID · 1 min' }, { v: 4.1, u: 'min', l: 'Median trip', s: 'Derived' }],
|
||||
brief: { lede: 'Remote ID broadcasts let anyone replay a corridor: out and back at 60 m, a hover-and-lower at each drop.', series: [{ label: 'Drops per hour', unit: '', data: [0, 0, 4, 12, 19, 22, 25, 21, 24, 27, 23, 18], src: 'Remote ID · hourly', fmt: (v) => v }], sources: [['Remote ID receivers (illustrative)', '1 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'marathon', short: 'Lakeshore 42K', fam: 'ground', kind: 'race', section: 'always', live: true, aspect: 1.0, ageS: 10, watchers: 4600,
|
||||
title: 'Lakeshore 42K — runner trackers', place: 'Chicago lakefront (fictional race)', lat: 41.89, lon: -87.625,
|
||||
camera: { lat: 41.875, lon: -87.630, range: 0.0026, heading: 0.15, tilt: 1.05 }, layers: ['venues', 'transit', 'traffic'], source: 'Race trackers (illustrative)', time: dayReplay(41.88, -87.63, 9.5),
|
||||
metric: () => ['260<small>trackers</small>', 'leader at 31.4 km · 2:21 pace'], metrics: () => [{ v: 260, u: '', l: 'Live trackers', s: 'Race app · 10 s' }, { v: 31.4, u: 'km', l: 'Leader', s: '10 s' }, { v: '3:21', u: '/km', l: 'Median pace', s: 'Derived' }],
|
||||
brief: { lede: 'Bibs broadcast positions every 10 seconds; the field has stretched to 9 km.', series: [{ label: 'Leader pace', unit: 's/km', data: walk(30, 200, 1.5, 0, 41), band: 2, src: 'Tracker · 1 km', fmt: (v) => Math.round(v) }], sources: [['Race trackers (fictional race)', '10 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'gnss', short: 'GNSS jamming', fam: 'infra', kind: 'gnss', section: 'now', live: true, aspect: 1.25, ageS: 600, watchers: 6100,
|
||||
title: 'GNSS jamming hot spots', place: 'Baltic · Black Sea · Eastern Med', lat: 50, lon: 27,
|
||||
camera: { lat: 49.0, lon: 26.0, range: 0.42, heading: 0.0, tilt: 0.35 }, layers: ['gnss', 'flights', 'military'], source: 'Aircraft integrity reports',
|
||||
metric: () => ['8<small>hot spots</small>', 'Baltic 41% · E. Med 28%'], metrics: () => [{ v: 8, u: '', l: 'Hot spots', s: '10 min' }, { v: 41, u: '%', l: 'Baltic flights affected', s: '10 min' }, { v: 28, u: '%', l: 'E. Med', s: '10 min' }],
|
||||
brief: { lede: 'Aircraft broadcast their own position-integrity; mapping drops in that integrity reveals where navigation signals are being jammed.', series: [{ label: 'Affected flights', unit: '%', data: walk(24, 30, 2, 0.4, 51), band: 5, src: 'NIC/NACp · 1 h', fmt: (v) => Math.round(v) }], sources: [['ADS-B integrity fields', '10 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'starlink', fam: 'orbit', kind: 'train', section: 'near', live: true, aspect: 1.0, ageS: 90, watchers: 8800,
|
||||
title: 'Fresh satellite train — visible at dusk', place: 'Low Earth orbit, 312 km', lat: 30, lon: -60,
|
||||
camera: null, layers: ['trains', 'sats', 'tracks'], source: 'TLE propagation', dyn: true,
|
||||
metric: () => ['24<small>satellites</small>', 'orbit raising · 312 km'], metrics: () => [{ v: 24, u: '', l: 'Satellites in train', s: 'TLE · 90 s' }, { v: 312, u: 'km', l: 'Altitude', s: 'TLE' }, { v: 7.73, u: 'km/s', l: 'Speed', s: 'Computed' }],
|
||||
brief: { lede: 'A freshly launched batch flies in a tight line before raising orbit — at dusk it looks like a string of pearls.', series: [{ label: 'Altitude (orbit raising)', unit: 'km', data: Array.from({ length: 20 }, (_, i) => 290 + i * 1.2), src: 'TLE · daily', fmt: (v) => Math.round(v) }], sources: [['Two-line elements', '90 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'squawk', fam: 'sky', kind: 'squawk', section: 'now', live: true, aspect: 1.25, ageS: 300, watchers: 12000,
|
||||
title: 'Squawk 7700 — diversion in progress', place: 'Over the North Atlantic', lat: 50, lon: -30, dyn: true,
|
||||
camera: null, layers: ['squawk', 'flights'], source: 'ADS-B',
|
||||
metric: () => ['7700<small>emergency</small>', 'diverting · descending'], metrics: () => [{ v: 7700, u: '', l: 'Squawk', s: 'ADS-B · 5 s' }, { v: 'FL240', u: '', l: 'Descending through', s: '5 s' }, { v: 34, u: 'min', l: 'To diversion', s: 'Computed' }],
|
||||
brief: { lede: 'An airliner set its transponder to 7700 and turned toward the nearest suitable airport. Replay the last 20 minutes.', series: [{ label: 'Altitude', unit: 'ft', data: Array.from({ length: 30 }, (_, i) => (i < 8 ? 37000 : 37000 - (i - 8) * 600)), src: 'ADS-B · 1 min', fmt: (v) => fmt.n(v) }], sources: [['ADS-B', '5 s', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'strait', short: 'Singapore Strait', fam: 'sea', kind: 'strait', section: 'always', live: true, aspect: 1.5, ageS: 40, watchers: 3900,
|
||||
title: 'Singapore Strait — the world’s busiest lane', place: 'Singapore', lat: 1.25, lon: 103.9,
|
||||
camera: { lat: 1.18, lon: 103.85, range: 0.022, heading: 0.1, tilt: 0.9 }, layers: ['ais', 'tankers', 'ports', 'flights'], source: 'AIS',
|
||||
metric: () => ['1,912<small>transits/day</small>', '90 at anchor · 2 dark'], metrics: () => [{ v: '1,912', u: '', l: 'Transits / 24 h', s: 'AIS · 40 s' }, { v: 90, u: '', l: 'At anchor', s: 'AIS · 40 s' }, { v: 2, u: '', l: 'AIS gaps', s: '1 h' }],
|
||||
brief: { lede: 'Two traffic separation lanes, one of the densest anchorages anywhere.', series: [{ label: 'Transits per hour', unit: '', data: walk(24, 80, 4, 0, 61).map(Math.round), src: 'AIS · hourly', fmt: (v) => v }], sources: [['AIS', '40 s', 'live']] },
|
||||
});
|
||||
|
||||
for (const s of S) if (s.time && s.time.replay) { s.live = false; s.replayLabel = 'Today ' + new Date(s.time.at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }) + ' UTC'; }
|
||||
SIT.list = S;
|
||||
SIT.byId = Object.fromEntries(S.map((s) => [s.id, s]));
|
||||
SIT.sections = [
|
||||
{ id: 'now', title: 'Happening now', sub: 'Live' }, { id: 'near', title: 'Near you', sub: GE.ME.name }, { id: 'recon', title: 'Reconstructions', sub: '4D · signed' }, { id: 'always', title: 'Always on', sub: 'Worth a look' },
|
||||
];
|
||||
// dynamic cameras
|
||||
SIT.cameraFor = function (s) {
|
||||
if (s.id === 'starlink') { const g = GE.world.sun.gmst; const p = GE.satAt(GE.SATS.find((x) => x.kind === 5), GE.world.t, g); const [la, lo] = geo.ll(p); return { lat: la - 6, lon: lo - 8, range: 0.32, heading: 0.6, tilt: 0.85 }; }
|
||||
if (s.id === 'squawk') { const f = GE.FLIGHTS[GE.world.squawks[0]]; const p = GE.flightAt(f, GE.world.t) || { lat: 50, lon: -30 }; return { lat: p.lat - 1.2, lon: p.lon - 1, range: 0.06, heading: 0.3, tilt: 0.85 }; }
|
||||
if (s.id === 'iss') { const p = GE.satAt(GE.ISS, GE.world.t, GE.world.sun.gmst); const [la, lo] = geo.ll(p); return { lat: la - 9, lon: lo - 4, range: 0.48, heading: 0.35, tilt: 0.78 }; }
|
||||
return s.camera;
|
||||
};
|
||||
SIT.pin = (s) => { if (s.id === 'starlink' || s.id === 'squawk' || s.id === 'iss') { const c = SIT.cameraFor(s); return null; } return { id: s.id, lat: s.lat, lon: s.lon, title: s.title, col: [...hex2rgb(fam(s.fam)), 1], seed: hash01(s.id.length) }; };
|
||||
@@ -0,0 +1,147 @@
|
||||
/* Shell: tabs, Liquid-Glass tab bar with sliding lens, tab-bar accessory (followed situation),
|
||||
minimize-on-scroll, layout (phone / two-pane / hinge), overlay labels, toasts. */
|
||||
const APP = (GE.app = { tab: 'now', mode: '', following: 'cyclone', wide: false });
|
||||
const appEl = () => $('#app');
|
||||
APP.isWide = () => { try { return matchMedia('(min-width: 600px)').matches; } catch { return innerWidth >= 600; } };
|
||||
|
||||
APP.toast = function (html, ms = 2200) {
|
||||
const t = $('#toast'); t.innerHTML = html; t.classList.add('on'); clearTimeout(APP._tt); APP._tt = setTimeout(() => t.classList.remove('on'), ms);
|
||||
};
|
||||
APP.setMode = function (m) { APP.mode = m; appEl().dataset.mode = m; bus.emit('mode', m); APP.updateChrome(); };
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
APP.setTab = function (tab, opts = {}) {
|
||||
const prev = APP.tab;
|
||||
if (prev === tab && !opts.force) { if (tab === 'now') $('#v-now').scrollTo({ top: 0, behavior: 'smooth' }); return; }
|
||||
if (APP.mode === 'situation') GE.sheet.close(true);
|
||||
if (APP.mode === 'ride') GE.ride.exit(true);
|
||||
if (prev === 'capture') GE.capture.exit();
|
||||
if (prev === 'threads' && GE.threads.ws) GE.threads.closeWs(true);
|
||||
APP.tab = tab; appEl().dataset.tab = tab;
|
||||
$$('.tab').forEach((b) => { b.classList.toggle('on', b.dataset.tab === tab); b.setAttribute('aria-selected', b.dataset.tab === tab); });
|
||||
APP.moveLens();
|
||||
const wide = APP.isWide();
|
||||
$$('.pane .view').forEach((v) => v.classList.remove('on'));
|
||||
const map = { now: '#v-now', threads: '#v-threads', you: '#v-you', globe: wide ? '#v-layers' : null };
|
||||
if (map[tab]) $(map[tab]).classList.add('on');
|
||||
$('#v-capture').classList.toggle('on', tab === 'capture');
|
||||
if (tab === 'capture') GE.capture.enter();
|
||||
if (tab === 'globe' && wide) GE.gui.renderLayersPane();
|
||||
APP.minimize(false);
|
||||
APP.cameraForTab(tab, prev);
|
||||
APP.updateChrome();
|
||||
haptic(6);
|
||||
bus.emit('tab', tab);
|
||||
};
|
||||
APP.cameraForTab = function (tab, prev) {
|
||||
const Cm = GE.cam; const wide = APP.isWide();
|
||||
if (tab === 'now') {
|
||||
Cm.flyTo(APP.heroCam(), { duration: prev ? 1.2 : 0 });
|
||||
Cm.autoSpin = 0.3;
|
||||
} else if (tab === 'globe') {
|
||||
Cm.autoSpin = 0;
|
||||
if (prev === 'now' || prev === 'you' || prev === 'threads' || !prev) Cm.flyTo({ range: Math.min(2.7, Math.max(Cm.range, 0.5)), shiftY: wide ? 0 : 0.06, shiftX: 0, tilt: 0 }, { duration: 1.0 });
|
||||
} else if (tab === 'you' || tab === 'threads') { Cm.autoSpin = 0.3; if (!wide) Cm.flyTo(APP.heroCam(), { duration: 0.9 }); }
|
||||
};
|
||||
APP.moveLens = function () {
|
||||
const on = $(`.tab[data-tab="${APP.tab}"]`); const lens = $('#tablens'); if (!on || !lens) return;
|
||||
lens.style.transform = `translateX(${on.offsetLeft}px)`; lens.style.width = on.offsetWidth + 'px';
|
||||
};
|
||||
APP.minimize = function (on) {
|
||||
if (APP._mini === on) return; APP._mini = on;
|
||||
$('#tabwrap').classList.toggle('mini', on);
|
||||
$('#accHost').classList.toggle('hidden', on || !APP.showAcc());
|
||||
setTimeout(APP.moveLens, 460); requestAnimationFrame(APP.moveLens);
|
||||
};
|
||||
APP.showAcc = () => !!APP.following && (APP.tab === 'now' || APP.tab === 'threads' || APP.tab === 'you' || (APP.isWide() && APP.tab === 'globe')) && !APP.mode;
|
||||
APP.updateChrome = function () {
|
||||
const ride = APP.mode === 'ride', sit = APP.mode === 'situation';
|
||||
$('#tabwrap').classList.toggle('hidden', ride || (sit && !APP.isWide()) || APP.intro);
|
||||
$('#accHost').classList.toggle('hidden', !APP.showAcc() || APP._mini || APP.intro);
|
||||
$('#back').classList.toggle('on', sit);
|
||||
$('#sitbtns').classList.toggle('on', sit);
|
||||
// globe ribbon/dock offsets when the sheet is open (phone)
|
||||
const gb = $('#gbottom');
|
||||
gb.style.opacity = sit && !APP.isWide() ? '0' : ''; gb.style.pointerEvents = sit && !APP.isWide() ? 'none' : '';
|
||||
};
|
||||
|
||||
/* ---------- accessory ---------- */
|
||||
APP.renderAccessory = function () {
|
||||
const s = GE.SIT.byId[APP.following]; if (!s) return;
|
||||
const html = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span><svg class="spk" width="54" height="22" viewBox="0 0 54 22">${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}</svg><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
|
||||
$('#acc').innerHTML = html;
|
||||
$('#accMini').innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span>`;
|
||||
};
|
||||
function sparkPath(data, w, h, col, sw = 1.6) {
|
||||
const mn = Math.min(...data), mx = Math.max(...data), n = data.length;
|
||||
const pts = data.map((v, i) => [(i / (n - 1)) * (w - 4) + 2, h - 3 - ((v - mn) / (mx - mn || 1)) * (h - 6)]);
|
||||
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join('');
|
||||
const l = pts[pts.length - 1];
|
||||
return `<path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${l[0]}" cy="${l[1]}" r="2.4" fill="${col}"/>`;
|
||||
}
|
||||
GE.sparkPath = sparkPath;
|
||||
|
||||
/* ---------- overlay labels on the globe ---------- */
|
||||
const LBL = { els: new Map() };
|
||||
APP.updateLabels = function (view) {
|
||||
const host = $('#labels'); if (!host) return;
|
||||
const want = [];
|
||||
const showPins = (APP.tab === 'globe' || APP.isWide()) && !APP.mode && !APP.intro;
|
||||
if (showPins) for (const s of GE.SIT.list) {
|
||||
if (s.dyn) continue;
|
||||
const short = s.short || s.title.split(/ [—–-] /)[0].slice(0, 28);
|
||||
want.push({ k: 's:' + s.id, lat: s.lat, lon: s.lon, alt: 0.2, pr: s.watchers, html: `<i style="--c:${GE.FAM[s.fam].color}"></i>${esc(short)}`, w: short.length * 6.6 + 32, cls: 'glass', onclick: () => GE.sheet.open(s.id, { fromGlobe: true }) });
|
||||
}
|
||||
if (GE.world.sel && GE.world.sel.v && APP.mode !== 'ride') want.push({ k: 'sel', v: GE.world.sel.v, pr: 1e9, html: `<b>${esc(GE.world.sel.title)}</b>`, w: 90, cls: 'plain tag' });
|
||||
for (const [k, l] of GE.extraLabels || []) want.push({ k, pr: 1e8, w: 120, ...l });
|
||||
want.sort((a, b) => b.pr - a.pr);
|
||||
const placed = []; const shown = new Set();
|
||||
const cap = view.range > 1.2 ? 7 : view.range > 0.3 ? 12 : 40;
|
||||
for (const l of want) {
|
||||
const p = l.v || geo.vec(l.lat, l.lon, l.alt || 0);
|
||||
const s = GE.world.project(view, p);
|
||||
if (!s || !s.vis || shown.size >= cap) continue;
|
||||
const tag = l.cls && l.cls.includes('tag');
|
||||
const box = tag ? [s.x + 10, s.y - 13, s.x + 10 + l.w, s.y + 13] : [s.x - l.w / 2, s.y - 36, s.x + l.w / 2, s.y - 8];
|
||||
if (placed.some((b) => !(box[2] < b[0] - 4 || box[0] > b[2] + 4 || box[3] < b[1] - 3 || box[1] > b[3] + 3))) continue;
|
||||
placed.push(box); shown.add(l.k);
|
||||
let el = LBL.els.get(l.k);
|
||||
if (!el) { el = h('button', { class: 'lbl ' + (l.cls || '') }); el.innerHTML = l.html; if (l.onclick) el.addEventListener('click', l.onclick); else el.style.pointerEvents = 'none'; host.append(el); LBL.els.set(l.k, el); }
|
||||
else if (el._html !== l.html) el.innerHTML = l.html;
|
||||
el._html = l.html; el.style.opacity = '1';
|
||||
el.style.transform = `translate3d(${s.x.toFixed(1)}px, ${(s.y - (tag ? 0 : 8)).toFixed(1)}px, 0) translate(${tag ? '10px, -50%' : '-50%, -100%'})`;
|
||||
}
|
||||
for (const [k, el] of LBL.els) if (!shown.has(k)) { if (!want.some((w) => w.k === k)) { el.remove(); LBL.els.delete(k); } else el.style.opacity = '0'; }
|
||||
};
|
||||
APP.heroCam = function () {
|
||||
const W = GE.world; const sun = W.sun;
|
||||
if (!APP._heroLon || Math.abs((APP._heroT || 0) - Date.now()) > 600000) {
|
||||
let best = sun.lon + 70, bs = -1;
|
||||
for (const off of [-80, -65, 65, 80]) { const lon0 = sun.lon + off; let sum = 0; for (let la = -50; la <= 65; la += 7) for (let lo = -60; lo <= 60; lo += 7) { const L = lon0 + lo; const v = geo.vec(la, L); if (v3.dot(v, sun.dir) < -0.05 && geo.dist(24, lon0, la, L) < 1.15) sum += W.lightsAt(la, ((L + 540) % 360) - 180); } if (sum > bs) { bs = sum; best = lon0; } }
|
||||
APP._heroLon = ((best + 540) % 360) - 180; APP._heroT = Date.now();
|
||||
}
|
||||
const sl = APP._heroLon - 66; return APP.isWide() ? { lat: 20, lon: sl + 62, range: 2.6, heading: 0, tilt: 0, shiftY: 0, shiftX: 0 } : { lat: 24, lon: sl + 66, range: 2.85, heading: 0, tilt: 0, shiftY: 0.5, shiftX: 0 }; };
|
||||
GE.extraLabels = new Map();
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
APP.initShell = function () {
|
||||
$$('.tab').forEach((b) => b.addEventListener('click', () => APP.setTab(b.dataset.tab)));
|
||||
const openFollowed = () => GE.sheet.open(APP.following);
|
||||
$('#acc').addEventListener('click', openFollowed); $('#accMini').addEventListener('click', openFollowed);
|
||||
$('#btnSearch').addEventListener('click', () => GE.gui.openSearch());
|
||||
// minimize tab bar on scroll (feed-like views)
|
||||
for (const id of ['#v-now', '#v-threads', '#v-you']) {
|
||||
const v = $(id); let last = 0;
|
||||
v.addEventListener('scroll', () => {
|
||||
const y = v.scrollTop; const dy = y - last; last = y;
|
||||
if (y < 40) APP.minimize(false); else if (dy > 4) APP.minimize(true); else if (dy < -6) APP.minimize(false);
|
||||
const nb = v.querySelector('.navbar'); if (nb) nb.classList.toggle('on', y > (id === '#v-now' ? (APP.isWide() ? 90 : 250) : 60));
|
||||
clearTimeout(v._st); v._st = setTimeout(() => APP.minimize(false), 1400);
|
||||
bus.emit('scroll', { id, y });
|
||||
}, { passive: true });
|
||||
}
|
||||
addEventListener('resize', () => { APP.moveLens(); const w = APP.isWide(); if (w !== APP.wide) { APP.wide = w; APP.setTab(APP.tab, { force: true }); } });
|
||||
APP.wide = APP.isWide();
|
||||
setInterval(APP.renderAccessory, 1000); APP.renderAccessory();
|
||||
requestAnimationFrame(APP.moveLens);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/* Now: RedNote-style two-column masonry of living situation cards. Thumbnails are rendered from the
|
||||
same WebGL globe in an offscreen pass (texture-crop fallback first, so there is never a blank). */
|
||||
const NOWV = (GE.now = { filter: 'all' });
|
||||
const TH = (GE.thumbs = { q: [], chains: new Map(), done: new Map() });
|
||||
|
||||
/* ---------- thumbnail renderer ---------- */
|
||||
Cam.viewFrom = function (spec, w, h) {
|
||||
const save = { lat: Cam.lat, lon: Cam.lon, range: Cam.range, heading: Cam.heading, tilt: Cam.tilt, shiftX: Cam.shiftX, shiftY: Cam.shiftY, rig: Cam.rig };
|
||||
Object.assign(Cam, { shiftX: 0, shiftY: 0, heading: 0, tilt: 0 }, spec); Cam.rig = null;
|
||||
const v = Cam.view(w, h); Object.assign(Cam, save); return v;
|
||||
};
|
||||
TH.chainFor = function (w, h) { const k = w + 'x' + h; if (!TH.chains.has(k)) TH.chains.set(k, { chain: GL.chain(w, h), out: GL.target(w, h, { hdr: false }) }); return TH.chains.get(k); };
|
||||
TH.render = function (spec, w, h, o = {}) { return TH.renderView(Cam.viewFrom(spec, w, h), w, h, o); };
|
||||
TH.renderView = function (view, w, h, o = {}) {
|
||||
const gl = GL.gl; const { chain, out } = TH.chainFor(w, h);
|
||||
const W = GE.world;
|
||||
const at = o.at ?? null;
|
||||
const t = at != null ? (at - NOW) / 1000 : W.t;
|
||||
const sun = at != null ? astro.sun(at) : W.sun;
|
||||
const offsetH = at != null ? (at - Date.now()) / 3600000 : W.offsetH;
|
||||
const saved = W.layers.map((l) => l.on);
|
||||
if (o.layers) for (const id of o.layers) if (W.layerById[id]) W.layerById[id].on = true;
|
||||
W.render(view, chain, out.fb, { t: o.t ?? t, sun: o.sun ?? sun, offsetH: o.offsetH ?? offsetH, simMs: o.simMs ?? at ?? W.simMs, lens: o.lens || 'cinematic', outW: w, outH: h, vpW: w, vpH: h, pxScale: o.px ?? 1.35, aurora: o.aurora, exposure: o.exposure ?? 1.08, grain: false, focus: o.focus, extraSprites: o.extraSprites, extraLines: o.extraLines, steps: 10, lineK: 1.25, cloudK: o.cloudK });
|
||||
W.layers.forEach((l, i) => (l.on = saved[i]));
|
||||
const px = new Uint8Array(w * h * 4);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
const cv = o.canvas || document.createElement('canvas'); cv.width = w; cv.height = h;
|
||||
const cx = cv.getContext('2d'); const id = cx.createImageData(w, h);
|
||||
for (let y = 0; y < h; y++) id.data.set(px.subarray((h - 1 - y) * w * 4, (h - y) * w * 4), y * w * 4);
|
||||
cx.putImageData(id, 0, 0);
|
||||
return cv;
|
||||
};
|
||||
TH.situationSpec = (s) => s.thumbCam || GE.SIT.cameraFor(s);
|
||||
TH.renderSituation = function (s, canvas) {
|
||||
const w = canvas.width, hh = canvas.height;
|
||||
const extras = GE.extrasFor ? GE.extrasFor(s.id) : null;
|
||||
return TH.render(TH.situationSpec(s), w, hh, { at: s.time?.at ?? null, layers: s.layers, canvas, aurora: s.auroraK, extraSprites: extras?.sprites, extraLines: extras?.lines, focus: s.kind === 'recon' ? null : null });
|
||||
};
|
||||
TH.enqueue = function (fn) { TH.q.push(fn); };
|
||||
TH.step = function (budgetMs = 10) {
|
||||
const t0 = performance.now(); let n = 0;
|
||||
while (TH.q.length && (n === 0 || performance.now() - t0 < budgetMs)) { const fn = TH.q.shift(); try { fn(); } catch (e) { console.warn('thumb', e); } n++; }
|
||||
};
|
||||
// texture-crop fallback, drawn instantly
|
||||
TH.fallback = function (s, canvas) {
|
||||
const W = GE.world; const cx = canvas.getContext('2d'); const w = canvas.width, hh = canvas.height;
|
||||
const spec = TH.situationSpec(s) || { lat: s.lat, lon: s.lon, range: 0.3 };
|
||||
const spanDeg = clamp(spec.range * 70, 1.2, 120);
|
||||
const img = W.img?.day; if (!img) { cx.fillStyle = '#0b1220'; cx.fillRect(0, 0, w, hh); return; }
|
||||
const sx = ((spec.lon + 180) / 360) * img.width, sy = ((90 - spec.lat) / 180) * img.height;
|
||||
const sw = (spanDeg / 360) * img.width, sh = sw * (hh / w);
|
||||
cx.drawImage(img, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh);
|
||||
const el = astro.elev(s.time?.at ?? Date.now(), s.lat, s.lon);
|
||||
if (el < 4) { cx.globalAlpha = clamp((4 - el) / 12, 0, 0.92); cx.drawImage(W.img.night, sx - sw / 2, sy - sh / 2, sw, sh, 0, 0, w, hh); cx.globalAlpha = 1; }
|
||||
const g = cx.createRadialGradient(w / 2, hh / 2, Math.min(w, hh) * 0.2, w / 2, hh / 2, Math.max(w, hh) * 0.75);
|
||||
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.6)'); cx.fillStyle = g; cx.fillRect(0, 0, w, hh);
|
||||
};
|
||||
|
||||
/* ---------- feed ---------- */
|
||||
const famIcon = (f) => GE.FAM[f].icon;
|
||||
function cardHTML(s) {
|
||||
const c = GE.FAM[s.fam].color;
|
||||
const [m, ml] = s.metric();
|
||||
const badge = s.d4 ? `<span class="badge d4">4D</span><span class="badge replay">Thread</span>` : s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : `<span class="badge replay">${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}</span>`;
|
||||
const right = s.thread ? `<span class="stack">${['tamang', 'hydro', 'kora', 'geo'].map((k) => `<span class="avatar sm" style="--h:${GE.PEOPLE[k].hue}">${GE.PEOPLE[k].h[0].toUpperCase()}</span>`).join('')}</span>` : `<span class="r">${ic('eye', 'xs')}<span class="num">${fmt.k(s.watchers)}</span></span>`;
|
||||
return `<div class="thumb" style="aspect-ratio:${1}/${s.aspect}"><canvas width="360" height="${Math.round(360 * s.aspect)}"></canvas>
|
||||
<div class="tl">${badge}</div><div class="tr" style="--c:${c}">${ic(famIcon(s.fam), 'sm')}</div>
|
||||
<div class="bl"><div class="metric num" data-m>${m}</div><div class="mlabel num" data-ml>${ml}</div></div></div>
|
||||
<div class="meta"><div class="ttl">${esc(s.title)}</div><div class="src"><span class="l"><span style="width:6px;height:6px;border-radius:50%;background:${c};flex:none"></span><span data-age>${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}</span></span>${right}</div>
|
||||
${s.ride ? `<button class="cta" data-ride>${ic('helicopter', 'sm')}Jump into the chopper</button>` : ''}</div>`;
|
||||
}
|
||||
NOWV.render = function () {
|
||||
const v = $('#v-now');
|
||||
const now = new Date();
|
||||
const kick = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' });
|
||||
v.innerHTML = `<div class="navbar">Now</div>
|
||||
<div class="now-hero">
|
||||
<div class="lt"><div class="kicker"><span class="dot pulse"></span><span>${kick}</span>·<span class="num" id="nowClock">${fmt.hm(Date.now())} UTC</span></div>
|
||||
<div class="row"><h1>Now</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div></div>
|
||||
<div class="livebar"><span><b class="num" id="cSit">${GE.SIT.list.length}</b> situations</span><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num" id="cSat">${fmt.n(GE.SATS.length)}</b> satellites</span></div>
|
||||
</div>
|
||||
<div class="now-body"><div class="chips" id="nowChips">
|
||||
${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => `<button class="chip ${NOWV.filter === k ? 'on' : ''}" data-f="${k}">${l}</button>`).join('')}
|
||||
</div><div id="nowSecs"></div></div>`;
|
||||
$('#nowMe').addEventListener('click', () => APP.setTab('you'));
|
||||
$$('#nowChips .chip').forEach((b) => b.addEventListener('click', () => { NOWV.filter = b.dataset.f; $$('#nowChips .chip').forEach((x) => x.classList.toggle('on', x === b)); NOWV.renderSections(); haptic(4); }));
|
||||
NOWV.renderSections();
|
||||
};
|
||||
NOWV.renderSections = function () {
|
||||
const host = $('#nowSecs'); host.innerHTML = '';
|
||||
for (const sec of GE.SIT.sections) {
|
||||
if (NOWV.filter !== 'all' && NOWV.filter !== sec.id) continue;
|
||||
const items = GE.SIT.list.filter((s) => s.section === sec.id);
|
||||
const hd = h('div', { class: 'sec-h' }, h('h2', null, sec.title), h('span', null, sec.sub));
|
||||
const grid = h('div', { class: 'masonry' }); const cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols);
|
||||
const hs = [0, 0];
|
||||
for (const s of items) {
|
||||
const el = h('article', { class: 'card', 'data-id': s.id, tabindex: 0, role: 'button', 'aria-label': s.title }); el.innerHTML = cardHTML(s);
|
||||
const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += s.aspect + 0.62;
|
||||
el.addEventListener('click', (e) => { if (e.target.closest('[data-ride]')) { e.stopPropagation(); NOWV.open(s, el, { ride: true }); return; } NOWV.open(s, el); });
|
||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); NOWV.open(s, el); } });
|
||||
const cv = el.querySelector('canvas');
|
||||
const cached = TH.done.get(s.id);
|
||||
if (cached) cv.getContext('2d').drawImage(cached, 0, 0);
|
||||
else { TH.fallback(s, cv); TH.enqueue(() => { const c2 = document.createElement('canvas'); c2.width = cv.width; c2.height = cv.height; TH.renderSituation(s, c2); TH.done.set(s.id, c2); $$(`.card[data-id="${s.id}"] canvas, .tcard[data-id="${s.id}"] canvas`).forEach((x) => { const xc = x.getContext('2d'); xc.drawImage(c2, 0, 0, x.width, x.height); }); }); }
|
||||
}
|
||||
host.append(hd, grid);
|
||||
}
|
||||
};
|
||||
NOWV.tick = function () {
|
||||
const W = GE.world;
|
||||
const c = W.counts; const air = (c.flights || 0) + (c.military || 0) + (c.ga || 0) + GE.HELI_CIRCUITS.length;
|
||||
const a = $('#cAir'); if (a) a.textContent = fmt.n(air);
|
||||
const sh = $('#cShip'); if (sh) sh.textContent = fmt.n(GE.SHIPS.length);
|
||||
const ck = $('#nowClock'); if (ck) ck.textContent = fmt.hm(Date.now()) + ' UTC';
|
||||
for (const el of $$('.card[data-id]')) {
|
||||
const s = GE.SIT.byId[el.dataset.id]; if (!s) continue;
|
||||
const [m, ml] = s.metric(); const me = el.querySelector('[data-m]'), mle = el.querySelector('[data-ml]');
|
||||
if (me && me.innerHTML !== m) { me.innerHTML = m; } if (mle && mle.innerHTML !== ml) mle.innerHTML = ml;
|
||||
const ag = el.querySelector('[data-age]'); if (ag) ag.textContent = `${s.source} · ${fmt.ago(Date.now() - s.updatedAt)}`;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- hero morph: card → live globe ---------- */
|
||||
NOWV.open = function (s, cardEl, o = {}) {
|
||||
const wide = APP.isWide();
|
||||
const thumb = cardEl?.querySelector('.thumb canvas');
|
||||
if (wide || !thumb || prefersReduced()) { GE.sheet.open(s.id, { ...o, fade: !wide }); return; }
|
||||
const r = thumb.getBoundingClientRect(); const app = $('#app').getBoundingClientRect();
|
||||
const M = $('#morph'); M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
|
||||
// target: the visible world above the sheet's medium detent
|
||||
const tw = app.width, th = app.height * 0.57;
|
||||
Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', borderRadius: '18px', opacity: '1', transition: 'none' });
|
||||
const sx = r.width / tw, sy = r.height / th;
|
||||
M.style.transformOrigin = '0 0';
|
||||
M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${sx}, ${sy})`;
|
||||
M.classList.add('on'); NOWV._morphCard = cardEl;
|
||||
cardEl.style.visibility = 'hidden';
|
||||
GE.sheet.open(s.id, { ...o, morph: true });
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const k = GE.HASH && GE.HASH.slow ? +GE.HASH.slow : 1; M.style.transition = `transform ${0.62 * k}s cubic-bezier(.2,.9,.2,1), border-radius ${0.62 * k}s ease, opacity ${0.5 * k}s ease ${0.34 * k}s`;
|
||||
M.style.transform = 'translate(0,0) scale(1,1)'; M.style.borderRadius = '0px'; M.style.opacity = '0';
|
||||
setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; }, 900 * k);
|
||||
}));
|
||||
};
|
||||
NOWV.foldBack = function (id) {
|
||||
const cardEl = $(`#v-now .card[data-id="${id}"]`); const M = $('#morph');
|
||||
if (!cardEl || prefersReduced() || APP.isWide()) return false;
|
||||
const thumb = cardEl.querySelector('.thumb canvas'); const app = $('#app').getBoundingClientRect();
|
||||
M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
|
||||
const tw = app.width, th = app.height * 0.57;
|
||||
Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', transformOrigin: '0 0', transition: 'none', transform: 'none', borderRadius: '0px', opacity: '0' });
|
||||
M.classList.add('on'); cardEl.style.visibility = 'hidden';
|
||||
requestAnimationFrame(() => {
|
||||
const r = thumb.getBoundingClientRect();
|
||||
M.style.transition = 'transform .55s cubic-bezier(.3,.8,.2,1), border-radius .55s ease, opacity .18s ease';
|
||||
M.style.opacity = '1'; M.style.borderRadius = '18px';
|
||||
M.style.transform = `translate(${r.left - app.left}px, ${r.top - app.top}px) scale(${r.width / tw}, ${r.height / th})`;
|
||||
setTimeout(() => { M.classList.remove('on'); cardEl.style.visibility = ''; cardEl.classList.add('flash'); setTimeout(() => cardEl.classList.remove('flash'), 1200); }, 600);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always
|
||||
wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with
|
||||
uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */
|
||||
const SH = (GE.sheet = { id: null, detent: 'medium' });
|
||||
const sheetEl = () => $('#sheet');
|
||||
SH.heights = function () {
|
||||
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
|
||||
return { peek: 112 + sab, medium: Math.round(H * 0.45), large: Math.round(H * 0.92), H };
|
||||
};
|
||||
SH.setDetent = function (d, animate = true) {
|
||||
SH.detent = d; const el = sheetEl(); if (APP.isWide()) { el.classList.add('wopen'); return; }
|
||||
const hs = SH.heights(); const full = el.offsetHeight || hs.H; const vis = hs[d];
|
||||
el.classList.toggle('drag', !animate);
|
||||
el.style.transform = `translateY(${Math.max(0, full - vis)}px)`;
|
||||
const sc = $('#sheetC'); sc.style.overflowY = d === 'large' ? 'auto' : 'hidden'; sc.style.touchAction = d === 'large' ? 'pan-y' : 'none';
|
||||
if (d !== 'large') sc.scrollTop = 0;
|
||||
SH.targetShift = d === 'large' ? 0.75 : clamp(vis / hs.H, 0, 0.8);
|
||||
};
|
||||
SH.open = function (id, o = {}) {
|
||||
const s = GE.SIT.byId[id]; if (!s) return;
|
||||
const W = GE.world;
|
||||
SH.id = id; SH.sit = s;
|
||||
if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); }
|
||||
APP.setMode('situation');
|
||||
// layers & time window
|
||||
SH._savedLayers = SH._savedLayers || W.layers.map((l) => l.on);
|
||||
for (const lid of s.layers || []) if (W.layerById[lid]) W.layerById[lid].on = true;
|
||||
bus.emit('layers', W.layers);
|
||||
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
|
||||
W.auroraK = s.auroraK ?? 0.85;
|
||||
GE.setExtras && GE.setExtras(s.id);
|
||||
// camera
|
||||
const cam = GE.SIT.cameraFor(s);
|
||||
const hs = SH.heights();
|
||||
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
|
||||
SH.targetShift = shiftY;
|
||||
Cam.autoSpin = 0;
|
||||
if (o.morph) { Cam.set({ ...cam, shiftY, shiftX: 0 }); Cam.flyTo({ range: cam.range * 0.82, heading: cam.heading + 0.12 }, { duration: 2.6 }); }
|
||||
else Cam.flyTo({ ...cam, shiftY, shiftX: 0 });
|
||||
W.focus = s.kind === 'recon' ? null : { lat: s.lat, lon: s.lon, k: 0.35 };
|
||||
SH.render(s);
|
||||
const el = sheetEl();
|
||||
if (APP.isWide()) { el.classList.add('wopen'); }
|
||||
else { el.classList.add('drag'); el.style.transform = 'translateY(100%)'; requestAnimationFrame(() => requestAnimationFrame(() => SH.setDetent(o.detent || 'medium'))); }
|
||||
if (o.ride && s.ride) setTimeout(() => GE.ride.start({ kind: 'heli', id: 0 }), 650);
|
||||
APP.updateChrome();
|
||||
};
|
||||
SH.close = function (silent) {
|
||||
if (!SH.id) return; const id = SH.id; const W = GE.world;
|
||||
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null);
|
||||
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
|
||||
W.setWindow(null); W.setTime(null); W.auroraK = 0.85;
|
||||
APP.setMode('');
|
||||
if (silent) return;
|
||||
const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`);
|
||||
if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
|
||||
else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); }
|
||||
};
|
||||
|
||||
/* drag */
|
||||
SH.initDrag = function () {
|
||||
const el = sheetEl(); let st = null;
|
||||
const begin = (e) => {
|
||||
if (APP.isWide()) return;
|
||||
const onGrab = e.target.closest('#grab'); const sc = $('#sheetC');
|
||||
if (!onGrab && SH.detent === 'large') return;
|
||||
if (e.target.closest('button, a, input, textarea, .evid, .multi')) return;
|
||||
st = { y: e.clientY, y0: e.clientY, t: performance.now(), base: new DOMMatrix(getComputedStyle(el).transform).m42, moved: 0, id: e.pointerId, samples: [] };
|
||||
el.setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
el.addEventListener('pointerdown', begin);
|
||||
el.addEventListener('pointermove', (e) => {
|
||||
if (!st || e.pointerId !== st.id) return;
|
||||
const dy = e.clientY - st.y0; st.moved = Math.max(st.moved, Math.abs(dy));
|
||||
if (st.moved < 4) return;
|
||||
el.classList.add('drag');
|
||||
const hs = SH.heights(); const full = el.offsetHeight; const minY = full - hs.large, maxY = full - hs.peek + 40;
|
||||
let y = st.base + dy; if (y < minY) y = minY - Math.sqrt(minY - y) * 3; if (y > maxY) y = maxY + Math.sqrt(y - maxY) * 3;
|
||||
el.style.transform = `translateY(${y}px)`;
|
||||
st.samples.push({ t: performance.now(), y: e.clientY }); if (st.samples.length > 6) st.samples.shift();
|
||||
SH.targetShift = clamp((full - y) / hs.H, 0, 0.8);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!st || e.pointerId !== st.id) return;
|
||||
const s0 = st.samples[0], s1 = st.samples[st.samples.length - 1];
|
||||
const v = s0 && s1 && s1.t > s0.t ? (s1.y - s0.y) / (s1.t - s0.t) : 0; // px/ms
|
||||
const hs = SH.heights(); const full = el.offsetHeight;
|
||||
const cur = new DOMMatrix(getComputedStyle(el).transform).m42; const visNow = full - cur + -v * 220;
|
||||
if (st.moved >= 4) {
|
||||
if (visNow < hs.peek * 0.6 && v > 0.6) { SH.close(); st = null; return; }
|
||||
const ds = ['peek', 'medium', 'large']; let best = 'medium', bd = 1e9; for (const d of ds) { const dd = Math.abs(hs[d] - visNow); if (dd < bd) { bd = dd; best = d; } }
|
||||
SH.setDetent(best); haptic(5);
|
||||
} else if (e.target.closest('#grab')) { SH.setDetent(SH.detent === 'peek' ? 'medium' : SH.detent === 'medium' ? 'large' : 'medium'); }
|
||||
st = null;
|
||||
};
|
||||
el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
|
||||
bus.on('globe-touch', () => { if (SH.id && SH.detent !== 'peek' && !APP.isWide()) SH.setDetent('peek'); });
|
||||
$('#back').addEventListener('click', () => { if (APP.mode === 'situation') SH.close(); });
|
||||
$('#sitFollow').addEventListener('click', () => { if (!SH.id) return; APP.following = SH.id === 'cyclone' || !SH.sit.accessory ? 'cyclone' : SH.id; APP.toast(`${ic('bell', 'sm')} Following · alerts within your radius`); haptic(10); });
|
||||
$('#sitShare').addEventListener('click', () => APP.toast(`${ic('link', 'sm')} Link copied · opens at this place & time`));
|
||||
};
|
||||
|
||||
/* ---------- content ---------- */
|
||||
function sparkSVG(sr, w) {
|
||||
const hgt = 46, pad = 3; const d = sr.data; const n = d.length;
|
||||
const band = sr.band || 0;
|
||||
let mn = Math.min(...d) - band, mx = Math.max(...d) + band; if (mx - mn < 1e-6) { mx += 1; mn -= 1; }
|
||||
const X = (i) => pad + (i / (n - 1)) * (w - pad * 2 - 34), Y = (v) => pad + 6 + (1 - (v - mn) / (mx - mn)) * (hgt - pad * 2 - 12);
|
||||
const split = sr.split ?? n - 1;
|
||||
const path = (a, b) => d.slice(a, b + 1).map((v, k) => `${k ? 'L' : 'M'}${X(a + k).toFixed(1)} ${Y(v).toFixed(1)}`).join('');
|
||||
let s = '';
|
||||
if (band) { const up = d.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v + band * (i > split ? 1 + (i - split) * 0.35 : 1)).toFixed(1)}`).join(''); const lo = d.map((v, i) => [X(i), Y(v - band * (i > split ? 1 + (i - split) * 0.35 : 1))]).reverse().map(([x, y]) => `L${x.toFixed(1)} ${y.toFixed(1)}`).join(''); s += `<path d="${up}${lo}Z" fill="rgba(124,196,255,.12)"/>`; }
|
||||
s += `<path d="${path(0, split)}" fill="none" stroke="#DCE6F5" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
|
||||
if (split < n - 1) s += `<path d="${path(split, n - 1)}" fill="none" stroke="#8AB4F8" stroke-width="1.5" stroke-dasharray="3 3"/>`;
|
||||
const iMax = d.indexOf(Math.max(...d)), iMin = d.indexOf(Math.min(...d));
|
||||
const f = sr.fmt || ((v) => v);
|
||||
s += `<circle cx="${X(iMax)}" cy="${Y(d[iMax])}" r="2.2" fill="#FF8A6B"/><text x="${X(iMax)}" y="${Y(d[iMax]) - 5}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMax])}</text>`;
|
||||
if (iMin !== iMax) s += `<circle cx="${X(iMin)}" cy="${Y(d[iMin])}" r="2.2" fill="#7CC4FF"/><text x="${X(iMin)}" y="${Y(d[iMin]) + 13}" font-size="9.5" fill="rgba(232,238,248,.6)" text-anchor="middle" font-family="var(--font)">${f(d[iMin])}</text>`;
|
||||
const ln = split; s += `<circle cx="${X(ln)}" cy="${Y(d[ln])}" r="3" fill="#fff"/><text x="${X(ln) + 6}" y="${Y(d[ln]) + 3.5}" font-size="10.5" font-weight="700" fill="#fff" font-family="var(--font)">${f(d[ln])}</text>`;
|
||||
if (split < n - 1) s += `<line x1="${X(split)}" x2="${X(split)}" y1="2" y2="${hgt - 2}" stroke="rgba(255,255,255,.18)" stroke-dasharray="2 2"/><text x="${X(split) + 3}" y="${hgt - 1}" font-size="8.5" fill="#8AB4F8" font-family="var(--font)">forecast</text>`;
|
||||
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
|
||||
}
|
||||
GE.sparkSVG = sparkSVG;
|
||||
function barsSVG(items, w) {
|
||||
const hgt = items.length * 22; let s = '';
|
||||
items.forEach(([k, v], i) => { const y = i * 22; s += `<text x="0" y="${y + 14}" font-size="12" fill="rgba(232,238,248,.75)" font-family="var(--font)">${k}</text><rect x="92" y="${y + 6}" width="${(w - 132) * v}" height="10" rx="3" fill="rgba(124,196,255,.55)"/><text x="${92 + (w - 132) * v + 6}" y="${y + 15}" font-size="11" fill="rgba(232,238,248,.55)" font-family="var(--font)">${Math.round(v * 100)}</text>`; });
|
||||
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
|
||||
}
|
||||
SH.render = function (s) {
|
||||
const c = GE.FAM[s.fam].color; const el = $('#sheetC');
|
||||
const ms = s.metrics();
|
||||
const live = s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : s.d4 ? `<span class="badge d4">4D</span>` : `<span class="badge replay">Replay</span>`;
|
||||
const acts = [
|
||||
`<button class="act pri" data-a="follow">${ic('bellplus')}Follow</button>`,
|
||||
s.ride ? `<button class="act gold" data-a="ride">${ic('helicopter')}Ride</button>` : `<button class="act" data-a="replay">${ic('clock')}Replay</button>`,
|
||||
`<button class="act" data-a="eye">${ic('video')}Nearest eye</button>`,
|
||||
`<button class="act" data-a="thread">${ic('threads')}Thread</button>`,
|
||||
].join('');
|
||||
const b = s.brief || {};
|
||||
el.innerHTML = `
|
||||
<div class="sh-hd"><div class="t"><div style="display:flex;gap:6px;align-items:center"><span class="badge fam" style="--c:${c}"><i></i>${GE.FAM[s.fam].name}</span>${live}<span style="font-size:12px;color:var(--text3)" class="num" data-sage>${fmt.ago(Date.now() - s.updatedAt)}</span></div>
|
||||
<h2>${esc(s.title)}</h2><div class="pl">${esc(s.place)} · <span class="mono">${fmt.ll(s.lat, s.lon, 2)}</span></div></div></div>
|
||||
<div class="metrics" id="shMetrics">${ms.map((m) => `<div class="metric"><div class="v">${m.v}<small>${m.u}</small></div><div class="l">${m.l}</div><div class="s">${m.s}</div></div>`).join('')}</div>
|
||||
<div class="acts sh-acts">${acts}</div>
|
||||
<div class="brief">
|
||||
${b.lede ? `<p>${esc(b.lede)}</p>` : ''}
|
||||
${b.series?.length ? `<h3>Signals</h3><div id="shSeries"></div>` : ''}
|
||||
${b.multiples ? `<h3>${esc(b.multiples.label)}</h3><div class="multi" id="shMulti"></div>` : ''}
|
||||
<h3>Evidence</h3><div class="evid" id="shEvid"></div>
|
||||
<h3>Sources</h3><div class="srcs">${(b.sources || []).map(([n, a, st]) => `<div class="r"><span class="st ${st === 'stale' ? 'stale' : ''}"></span><b style="font-weight:500">${esc(n)}</b><span>${esc(a)}</span></div>`).join('')}
|
||||
<div class="r"><span class="st"></span><b style="font-weight:500">Sun, terminator & time</b><span>computed</span></div></div>
|
||||
${s.thread ? `<div style="margin-top:18px"><button class="btn pri" data-a="thread" style="width:100%">${ic('threads')}Open the reconstruction</button></div>` : ''}
|
||||
<p style="margin-top:20px;font-size:11.5px;color:var(--text3)">Concept · illustrative data. People, handles, posts and claims shown here are fictional.</p>
|
||||
</div>`;
|
||||
const w = Math.max(240, (APP.isWide() ? $('#sheet').clientWidth : $('#app').clientWidth) - 36);
|
||||
if (b.series?.length) $('#shSeries').innerHTML = b.series.map((sr) => `<div class="sline"><span class="lab">${esc(sr.label)}</span><span class="val">${(sr.fmt || ((v) => v))(sr.data[sr.split ?? sr.data.length - 1])} ${esc(sr.unit)}</span>${sparkSVG(sr, w)}<div class="src"><span>${esc(sr.src)}</span><span>${sr.band ? '± band = uncertainty' : ''}</span></div></div>`).join('');
|
||||
if (b.multiples) {
|
||||
const mh = $('#shMulti');
|
||||
if (b.multiples.kind === 'bars') { mh.style.gridTemplateColumns = '1fr'; mh.innerHTML = barsSVG(b.multiples.items, w); }
|
||||
else for (const it of b.multiples.items) {
|
||||
const fig = h('figure'); const cv = h('canvas', { width: 180, height: 180 }); fig.append(cv, h('figcaption', { html: it.cap })); mh.append(fig);
|
||||
const spec = it.cam || { lat: s.lat, lon: s.lon, range: 0.2, heading: 0, tilt: 0.2 };
|
||||
TH.fallback(s, cv);
|
||||
TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 180, 180, { at: Date.now() + it.h * 3600000, layers: s.layers, canvas: cv, px: 1.0 }); });
|
||||
}
|
||||
}
|
||||
// evidence strip — each tile is rendered from the globe near the place (signed captures, illustrative)
|
||||
const ev = $('#shEvid'); const people = ['tamang', 'hydro', 'sky', 'ais', 'geo', 'crowd'];
|
||||
const cam0 = GE.SIT.cameraFor(s) || { lat: s.lat, lon: s.lon, range: 0.01 };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const p = GE.PEOPLE[people[(i + s.id.length) % people.length]];
|
||||
const cv = h('canvas', { width: 232, height: 168 }); const im = h('div', { class: 'im' }); im.append(cv); cv.style.cssText = 'width:100%;height:100%;display:block';
|
||||
const near = cam0.range < 0.02;
|
||||
const spec = { lat: cam0.lat + (hash01(i + 3) - 0.5) * (near ? 0.01 : 0.6), lon: cam0.lon + (hash01(i + 9) - 0.5) * (near ? 0.012 : 0.6), range: near ? cam0.range * (0.35 + i * 0.12) : 0.004 + i * 0.003, heading: i * 1.3 + 0.4, tilt: 1.25 };
|
||||
TH.fallback({ ...s, thumbCam: spec }, cv);
|
||||
TH.enqueue(() => { if (!document.body.contains(cv)) return; TH.render(spec, 232, 168, { at: s.time?.at ?? null, layers: s.layers, canvas: cv, px: 1.0, extraSprites: GE.world.activeExtras?.sprites, extraLines: GE.world.activeExtras?.lines }); });
|
||||
im.insertAdjacentHTML('beforeend', `<svg class="seal"><use href="#i-seal"/></svg>`);
|
||||
const card = h('div', { class: 'ev' }, im, h('div', { class: 'cap', html: `<b>@${p.h}</b>${['Signed · IMU ✓ GPS ±5 m', 'Signed · horizon ✓', 'Signed · 12 s clip', 'Signed · heading ✓', 'Signed · audio sync'][i]}<br><span class="num">${fmt.ago((i + 1) * 1000 * 60 * (7 + i * 13))}</span>` }));
|
||||
ev.append(card);
|
||||
}
|
||||
el.querySelectorAll('[data-a]').forEach((btn) => btn.addEventListener('click', () => SH.action(btn.dataset.a, s)));
|
||||
};
|
||||
SH.tick = function () {
|
||||
if (!SH.id) return; const s = SH.sit; const ms = s.metrics(); const host = $('#shMetrics'); if (!host) return;
|
||||
host.querySelectorAll('.metric').forEach((m, i) => { const v = m.querySelector('.v'); const html = `${ms[i].v}<small>${ms[i].u}</small>`; if (v.innerHTML !== html) v.innerHTML = html; });
|
||||
const a = $('[data-sage]'); if (a) a.textContent = fmt.ago(Date.now() - s.updatedAt);
|
||||
};
|
||||
SH.action = function (a, s) {
|
||||
haptic(6);
|
||||
if (a === 'follow') { APP.following = s.accessory ? s.id : 'cyclone'; APP.toast(`${ic('bell', 'sm')} Following ${esc(s.title.split(' — ')[0])}`); }
|
||||
if (a === 'ride') GE.ride.start({ kind: 'heli', id: 0 });
|
||||
if (a === 'replay') { GE.world.setTime(Date.now() - 6 * 3600000); APP.toast(`${ic('clock', 'sm')} Replaying the last 6 h — drag the ribbon`); SH.setDetent('peek'); }
|
||||
if (a === 'eye') { const off = geo.dest(s.lat, s.lon, 0.7, 0.8); Cam.flyTo({ lat: off[0], lon: off[1], range: 0.0006, tilt: 1.1 }); SH.setDetent('peek'); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera 0.8 km · illustrative`); }
|
||||
if (a === 'thread') { if (s.thread) { SH.close(true); APP.setTab('threads'); GE.threads.openWs(s.thread); } else APP.toast(`${ic('threads', 'sm')} Thread started · invite people to add observations`); }
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/* Globe chrome: lens dock (8 families), Control-Center layer grid (long-press = solo), visual lenses,
|
||||
haptic time ribbon (−72 h ← NOW → +120 h) with event pips, pick card, search. */
|
||||
const GUI = (GE.gui = {});
|
||||
const FAMS = Object.keys(GE.FAM);
|
||||
const LENSES = [
|
||||
['cinematic', 'Cinematic', 'linear-gradient(135deg,#0B2A55,#1E6FB8 45%,#F2B36B 80%,#2a1a10)'],
|
||||
['clear', 'Clear', 'linear-gradient(135deg,#3B6EA8,#8FB9E3 50%,#C9D9A4)'],
|
||||
['night', 'Night', 'radial-gradient(circle at 60% 40%,#FFC46B 0,#7A4A12 8%,#0B1426 30%,#04070D)'],
|
||||
['thermal', 'Thermal', 'linear-gradient(90deg,#08001A,#5A0F8C 30%,#E3263F 55%,#FF9F00 78%,#FFFBD6)'],
|
||||
['nvg', 'NVG', 'radial-gradient(circle,#B8FFB0 0,#2EA043 35%,#05200C 75%)'],
|
||||
];
|
||||
GUI.famCount = (f) => GE.world.layers.filter((l) => l.fam === f && l.on).reduce((s, l) => s + (l.count || 0), 0);
|
||||
|
||||
/* ---------- dock ---------- */
|
||||
GUI.renderDock = function () {
|
||||
const d = $('#dock'); const W = GE.world;
|
||||
d.innerHTML = FAMS.map((f) => { const F = GE.FAM[f]; const on = W.layers.some((l) => l.fam === f && l.on); return `<button class="fam glass ${on ? '' : 'off'} ${GUI.solo === f ? 'solo' : ''}" data-f="${f}" style="--c:${F.color}">${ic(F.icon)}${F.name}<span class="n num">${fmt.k(GUI.famCount(f))}</span></button>`; }).join('');
|
||||
$$('#dock .fam').forEach((b) => {
|
||||
let tm = null, long = false;
|
||||
b.addEventListener('pointerdown', () => { long = false; tm = setTimeout(() => { long = true; GUI.soloFam(b.dataset.f); }, 520); });
|
||||
const clr = () => clearTimeout(tm);
|
||||
b.addEventListener('pointerup', clr); b.addEventListener('pointerleave', clr); b.addEventListener('pointercancel', clr);
|
||||
b.addEventListener('click', () => { if (long) return; GUI.openCC(b.dataset.f); });
|
||||
b.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
});
|
||||
};
|
||||
GUI.soloFam = function (f) {
|
||||
const W = GE.world;
|
||||
if (GUI.solo === f) { if (GUI._preSolo) W.layers.forEach((l, i) => (l.on = GUI._preSolo[i])); GUI.solo = null; APP.toast(`Restored all families`); }
|
||||
else { if (!GUI.solo) GUI._preSolo = W.layers.map((l) => l.on); GUI.solo = f; W.layers.forEach((l) => (l.on = l.fam === f ? (l.on || ['flights', 'ais', 'sats', 'quakes', 'fires', 'cyclones', 'cctv', 'holo', 'venues', 'clouds', 'radar', 'gnss'].includes(l.id)) : l.id === 'clouds')); APP.toast(`<span style="color:${GE.FAM[f].color}">●</span> Soloed ${GE.FAM[f].name} · long-press to restore`); }
|
||||
haptic([8, 30, 8]); bus.emit('layers', W.layers);
|
||||
};
|
||||
|
||||
/* ---------- Control Center grid ---------- */
|
||||
GUI.ccHTML = function (inPane) {
|
||||
const W = GE.world;
|
||||
const lens = `<div class="lenses">${LENSES.map(([id, n, bg]) => `<button class="lens ${W.lens === id ? 'on' : ''}" data-lens="${id}"><span class="sw" style="background:${bg}"></span>${n}</button>`).join('')}</div>`;
|
||||
const fams = FAMS.map((f) => { const F = GE.FAM[f]; const ls = W.layers.filter((l) => l.fam === f);
|
||||
return `<section class="ccfam" id="ccf-${f}" style="--c:${F.color}"><div class="fh" data-fh="${f}"><i></i>${F.name}<span style="color:var(--text4);font-weight:600">${ls.length}</span><button class="solo" data-solo="${f}">${GUI.solo === f ? 'Restore' : 'Solo'}</button></div><div class="ccgrid">${ls.map((l) => `<button class="tile ${l.on ? 'on' : ''}" data-l="${l.id}"><span class="ti">${ic(l.icon || F.icon)}</span><span><div class="tn">${esc(l.name)}</div><div class="tc"><span class="st ${l.id === 'models' || l.id === 'imagery' ? 'stale' : ''}"></span><span class="num">${l.count ? fmt.n(l.count) : '—'}</span>${l.id === 'models' || l.id === 'imagery' ? ' · 3 h' : ''}</div></span></button>`).join('')}</div></section>`; }).join('');
|
||||
return `<div class="cch"><div><h2>Layers</h2><p>${W.layers.length} live layers in 8 families · long-press a family to solo</p></div>${inPane ? '' : `<button class="circ glass" id="ccX" aria-label="Close">${ic('x')}</button>`}</div><div class="stratum" style="margin-top:4px">Lens</div>${lens}${fams}`;
|
||||
};
|
||||
GUI.bindCC = function (root) {
|
||||
const W = GE.world;
|
||||
root.querySelectorAll('[data-l]').forEach((b) => b.addEventListener('click', () => { const l = W.layerById[b.dataset.l]; l.on = !l.on; b.classList.toggle('on', l.on); haptic(4); bus.emit('layers', W.layers); }));
|
||||
root.querySelectorAll('[data-lens]').forEach((b) => b.addEventListener('click', () => { W.setLens(b.dataset.lens); root.querySelectorAll('[data-lens]').forEach((x) => x.classList.toggle('on', x === b)); haptic(6); }));
|
||||
root.querySelectorAll('[data-solo]').forEach((b) => b.addEventListener('click', () => { GUI.soloFam(b.dataset.solo); GUI.refreshCC(); }));
|
||||
root.querySelectorAll('[data-fh]').forEach((hd) => { let tm; hd.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; tm = setTimeout(() => { GUI.soloFam(hd.dataset.fh); GUI.refreshCC(); }, 520); }); const c = () => clearTimeout(tm); hd.addEventListener('pointerup', c); hd.addEventListener('pointerleave', c); });
|
||||
root.querySelector('#ccX')?.addEventListener('click', GUI.closeCC);
|
||||
};
|
||||
GUI.openCC = function (fam) {
|
||||
if (APP.isWide()) { if (APP.tab !== 'globe') APP.setTab('globe'); GUI.renderLayersPane(); const t = $('#ccf-' + fam); if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
|
||||
const cc = $('#cc'); cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.classList.add('on');
|
||||
if (fam) requestAnimationFrame(() => { const t = $('#ccf-' + fam); if (t) cc.scrollTop = t.offsetTop - 60; });
|
||||
haptic(6);
|
||||
};
|
||||
GUI.refreshCC = function () { const cc = $('#cc'); if (cc.classList.contains('on')) { const st = cc.scrollTop; cc.innerHTML = GUI.ccHTML(false); GUI.bindCC(cc); cc.scrollTop = st; } if (APP.isWide() && APP.tab === 'globe') GUI.renderLayersPane(); };
|
||||
GUI.closeCC = () => $('#cc').classList.remove('on');
|
||||
GUI.renderLayersPane = function () { const v = $('#v-layers'); const st = v.scrollTop; v.innerHTML = `<div style="padding:calc(var(--sat) + 16px) 16px 140px">${GUI.ccHTML(true)}</div>`; GUI.bindCC(v); v.scrollTop = st; };
|
||||
|
||||
/* ---------- ribbon ---------- */
|
||||
const RB = { span: 30, vel: 0, drag: null };
|
||||
GUI.initRibbon = function () {
|
||||
const el = $('#ribbon'), cv = el.querySelector('canvas');
|
||||
const W = GE.world;
|
||||
const hpp = () => RB.span / Math.max(1, el.clientWidth);
|
||||
el.addEventListener('pointerdown', (e) => { if (e.target.closest('#rbNow')) return; el.setPointerCapture(e.pointerId); RB.drag = { x: e.clientX, t: performance.now(), lastHour: Math.floor(W.offsetH) }; RB.vel = 0; Cam.lastInput = performance.now(); });
|
||||
el.addEventListener('pointermove', (e) => {
|
||||
if (!RB.drag) return; const now = performance.now(); const dx = e.clientX - RB.drag.x; const dt = Math.max(1, now - RB.drag.t);
|
||||
const speed = Math.abs(dx) / dt; // px/ms
|
||||
const k = clamp(0.22 + speed * 1.1, 0.22, 2.6); // slow → minutes, fast → hours
|
||||
const dh = -dx * hpp() * k;
|
||||
GUI.scrubTo(W.offsetH + dh);
|
||||
RB.vel = dh / dt * 16; RB.drag.x = e.clientX; RB.drag.t = now;
|
||||
const hr = Math.floor(W.offsetH); if (hr !== RB.drag.lastHour) { RB.drag.lastHour = hr; haptic(3); }
|
||||
});
|
||||
const up = () => { RB.drag = null; };
|
||||
el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
|
||||
$('#rbNow').addEventListener('click', () => GUI.snapNow());
|
||||
GUI.drawRibbon();
|
||||
};
|
||||
GUI.scrubTo = function (h) {
|
||||
const W = GE.world; const win = W.getWindow(); const nowMs = Date.now();
|
||||
const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000;
|
||||
W.setTime(nowMs + clamp(h, lo, hi) * 3600000);
|
||||
};
|
||||
GUI.snapNow = function () {
|
||||
const W = GE.world; const win = W.getWindow();
|
||||
if (W.window) { GUI.scrubTo(((win.start + win.end) / 2 - Date.now()) / 3600000); return; }
|
||||
const from = W.offsetH; const t0 = performance.now(); haptic(12);
|
||||
const anim = () => { const u = clamp((performance.now() - t0) / 600, 0, 1); const e = ease.outBack(u); const v = from * (1 - e); if (u >= 1) { W.setTime(null); return; } W.setTime(Date.now() + v * 3600000); requestAnimationFrame(anim); };
|
||||
anim();
|
||||
};
|
||||
GUI.ribbonFrame = function () {
|
||||
if (!RB.drag && Math.abs(RB.vel) > 1e-4) { GUI.scrubTo(GE.world.offsetH + RB.vel); RB.vel *= 0.92; }
|
||||
GUI.drawRibbon();
|
||||
};
|
||||
GUI.drawRibbon = function () {
|
||||
const el = $('#ribbon'); if (!el || !el.offsetWidth) return; const cv = el.querySelector('canvas');
|
||||
const W = GE.world; const dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
const w = el.clientWidth, hh = el.clientHeight;
|
||||
if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(hh * dpr); }
|
||||
const cx = cv.getContext('2d'); cx.setTransform(dpr, 0, 0, dpr, 0, 0); cx.clearRect(0, 0, w, hh);
|
||||
const win = W.getWindow(); const nowMs = Date.now();
|
||||
RB.span = W.window ? Math.max(4, (win.end - win.start) / 3600000 * 0.7) : 30;
|
||||
const cur = W.offsetH; const pxh = w / RB.span; const X = (hr) => w / 2 + (hr - cur) * pxh;
|
||||
const lo = (win.start - nowMs) / 3600000, hi = (win.end - nowMs) / 3600000;
|
||||
// past / forecast regions
|
||||
const xNow = X(0);
|
||||
if (!W.window) {
|
||||
cx.fillStyle = 'rgba(138,180,248,.08)'; cx.fillRect(Math.max(0, xNow), 0, Math.max(0, w - Math.max(0, xNow)), hh);
|
||||
cx.save(); cx.beginPath(); cx.rect(Math.max(0, xNow), 0, w, hh); cx.clip(); cx.strokeStyle = 'rgba(138,180,248,.10)'; cx.lineWidth = 1; for (let x = Math.floor(xNow) - hh; x < w; x += 7) { cx.beginPath(); cx.moveTo(x, hh); cx.lineTo(x + hh, 0); cx.stroke(); } cx.restore();
|
||||
}
|
||||
// out-of-window shade
|
||||
cx.fillStyle = 'rgba(0,0,0,.35)'; if (X(lo) > 0) cx.fillRect(0, 0, X(lo), hh); if (X(hi) < w) cx.fillRect(X(hi), 0, w - X(hi), hh);
|
||||
// ticks
|
||||
const h0 = Math.floor(cur - RB.span / 2) - 1, h1 = Math.ceil(cur + RB.span / 2) + 1;
|
||||
cx.font = '600 10px ' + getComputedStyle(document.body).fontFamily; cx.textAlign = 'center';
|
||||
for (let k = h0; k <= h1; k++) {
|
||||
const x = X(k); const abs = new Date(nowMs + k * 3600000); const hrUTC = abs.getUTCHours();
|
||||
const major = hrUTC % 6 === 0, day = hrUTC === 0;
|
||||
cx.strokeStyle = day ? 'rgba(255,255,255,.55)' : major ? 'rgba(255,255,255,.32)' : 'rgba(255,255,255,.14)';
|
||||
cx.lineWidth = day ? 1.4 : 1;
|
||||
const th = day ? 18 : major ? 12 : 6; cx.beginPath(); cx.moveTo(x, hh - 6); cx.lineTo(x, hh - 6 - th); cx.stroke();
|
||||
if (major && pxh * 6 > 34) { cx.fillStyle = day ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)'; cx.fillText(day ? abs.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' }) : String(hrUTC).padStart(2, '0'), x, hh - 26 - (day ? 2 : 0)); }
|
||||
// minute ticks when zoomed (slow scrub)
|
||||
}
|
||||
// NOW marker
|
||||
if (!W.window && xNow > -2 && xNow < w + 2) { cx.fillStyle = '#FF453A'; cx.fillRect(xNow - 1, 4, 2, hh - 8); }
|
||||
// event pips
|
||||
const pips = GUI.pips();
|
||||
for (const p of pips) { const x = X(p.h); if (x < -6 || x > w + 6) continue; cx.fillStyle = p.c; cx.beginPath(); cx.arc(x, hh - 6, 3.2, 0, TAU); cx.fill(); cx.shadowColor = p.c; cx.shadowBlur = 8; cx.beginPath(); cx.arc(x, hh - 6, 2, 0, TAU); cx.fill(); cx.shadowBlur = 0; if (Math.abs(x - w / 2) < 60) { cx.fillStyle = 'rgba(255,255,255,.7)'; cx.font = '600 9.5px ' + getComputedStyle(document.body).fontFamily; cx.fillText(p.l, x, hh - 14); } }
|
||||
// readout
|
||||
const simMs = nowMs + cur * 3600000;
|
||||
$('#rbTime').textContent = fmt.utc(simMs);
|
||||
$('#rbRel').textContent = W.window ? '' : W.live ? '' : fmt.rel(cur);
|
||||
el.classList.toggle('future', cur > 0.02 && !W.window);
|
||||
el.classList.toggle('breath', W.live && !prefersReduced());
|
||||
const nb = $('#rbNow'); nb.classList.toggle('live', W.live); nb.innerHTML = W.window ? `${ic('clock', 'xs')}EVENT` : W.live ? '<span class="dot pulse"></span>LIVE' : 'NOW';
|
||||
const stt = $('#statusTxt'); if (stt) { stt.textContent = W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'; $('#statusTime').textContent = fmt.utc(simMs, false) + ' UTC'; $('#status .dot').style.background = W.live ? '' : cur > 0 ? 'var(--wx)' : 'var(--gold)'; }
|
||||
};
|
||||
GUI.pips = function () {
|
||||
const nowMs = Date.now(); const W = GE.world;
|
||||
if (W.window && GE.threads?.ws?.beats) return GE.threads.ws.beats.map((b) => ({ h: (b.at - nowMs) / 3600000, c: '#E9C46A', l: b.t }));
|
||||
return [{ h: GE.BIGQ.t / 3600, c: '#FF6A3D', l: 'M6.4' }, { h: 2.24, c: '#C9A7FF', l: 'Launch' }, { h: 40, c: '#FF6A3D', l: 'Landfall' }, { h: (GE.SIT.byId.aurora.time.at - nowMs) / 3600000, c: '#5CF2A0', l: 'Aurora' }, { h: -30, c: '#FF6A3D', l: 'Fire run' }, { h: (SIT._iss?.start - nowMs) / 3600000 || 3, c: '#C9A7FF', l: 'ISS' }];
|
||||
};
|
||||
|
||||
/* ---------- pick card ---------- */
|
||||
GUI.showPick = function (p) {
|
||||
const el = $('#pick'); const W = GE.world;
|
||||
if (!p) { el.classList.remove('on'); W.sel = null; W.selFlight = -1; W.selSat = -1; W.selRoute = null; return; }
|
||||
const famOf = { flight: 'sky', heli: 'sky', ship: 'sea', sat: 'orbit', quake: 'haz', holo: 'eyes', situation: 'haz' }[p.kind] || 'sky';
|
||||
const icn = { flight: 'airplane', heli: 'helicopter', ship: 'ferry', sat: 'iss', quake: 'wave', holo: 'seal', situation: 'radiowaves' }[p.kind];
|
||||
W.sel = { ...p, v: p.kind === 'flight' ? v3.scale(geo.vec(p.lat, p.lon), 1 + (p.altKm * W.altX) / EARTH_KM) : geo.vec(p.lat, p.lon, p.altKm || 0.1) };
|
||||
W.selFlight = p.kind === 'flight' ? p.id : -1; W.selSat = p.kind === 'sat' ? p.id : -1;
|
||||
if (p.kind === 'flight') { const m = p.meta; W.selRoute = (() => { const a = m.from, b = m.to; const pts = []; for (let i = 0; i <= 64; i++) { const t = i / 64; pts.push(v3.scale(geo.slerp(a.v, b.v, t), 1 + 0.002)); } return pts; })(); } else W.selRoute = null;
|
||||
let kv = '';
|
||||
if (p.kind === 'flight') kv = `<div><b>${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)}</b>ft</div><div><b>${Math.round(p.meta.f.speed / 1.852)}</b>kt</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div><div><b>${Math.round(p.meta.prog * 100)}%</b>route</div>`;
|
||||
else if (p.kind === 'ship') kv = `<div><b>${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
|
||||
else if (p.kind === 'heli') kv = `<div><b>${Math.round(p.altKm * 3281)}</b>ft AGL</div><div><b>${String(Math.round(((p.hdg * R2D) + 360) % 360)).padStart(3, '0')}°</b>hdg</div>`;
|
||||
else kv = `<div><b class="mono">${fmt.ll(p.lat, p.lon, 2)}</b>position</div>`;
|
||||
const canRide = p.kind === 'flight' || p.kind === 'heli';
|
||||
el.style.setProperty('--c', GE.FAM[famOf].color);
|
||||
el.innerHTML = `<div class="hd"><span class="ico">${ic(icn)}</span><div class="tt"><b>${esc(p.title)}</b><span>${esc(p.subtitle || '')}</span></div><button class="x" aria-label="Close">${ic('x', 'sm')}</button></div><div class="kv">${kv}</div>
|
||||
<div class="acts"><button class="act pri" data-p="follow">${ic('bellplus')}Follow</button><button class="act ${canRide ? 'gold' : ''}" data-p="ride" ${canRide ? '' : 'disabled style="opacity:.4"'}>${ic(p.kind === 'heli' ? 'helicopter' : 'airplane')}Ride</button><button class="act" data-p="eye">${ic('video')}Nearest eye</button><button class="act" data-p="thread">${ic('threads')}Thread</button></div>`;
|
||||
el.classList.add('on');
|
||||
el.querySelector('.x').addEventListener('click', () => GUI.showPick(null));
|
||||
el.querySelectorAll('[data-p]').forEach((b) => b.addEventListener('click', () => {
|
||||
const a = b.dataset.p; haptic(6);
|
||||
if (a === 'ride' && canRide) { GUI.showPick(null); GE.ride.start(p); }
|
||||
if (a === 'follow') APP.toast(`${ic('bell', 'sm')} Following ${esc(p.title)}`);
|
||||
if (a === 'eye') { Cam.flyTo({ lat: p.lat, lon: p.lon, range: Math.min(Cam.range, 0.004), tilt: 1.0 }); APP.toast(`${ic('video', 'sm')} Nearest eye · public camera ${(0.6 + hash01(p.id || 1) * 3).toFixed(1)} km · illustrative`); }
|
||||
if (a === 'thread') APP.toast(`${ic('threads', 'sm')} Thread started for ${esc(p.title)}`);
|
||||
}));
|
||||
};
|
||||
bus.on('tap', ({ x, y }) => {
|
||||
if (APP.mode === 'ride' || APP.tab === 'capture') return;
|
||||
if (APP.tab !== 'globe' && !APP.isWide()) return;
|
||||
const v = GE.mainView; if (!v) return;
|
||||
const p = GE.world.pick(v, x, y, 28);
|
||||
if (p && p.kind === 'situation') { GE.sheet.open(p.id, { fromGlobe: true }); return; }
|
||||
GUI.showPick(p); if (p) haptic(5);
|
||||
});
|
||||
|
||||
/* ---------- search ---------- */
|
||||
GUI.openSearch = function () {
|
||||
const el = $('#search');
|
||||
el.innerHTML = `<div class="res" id="sres"></div><div class="bar"><div class="field glass">${ic('search')}<input id="sq" placeholder="Places, situations, layers, callsigns" autocomplete="off" enterkeyhint="search"></div><button class="circ glass" id="sx" style="width:52px;height:52px">${ic('x')}</button></div>`;
|
||||
el.classList.add('on');
|
||||
const q = $('#sq'); const run = () => GUI.searchResults(q.value.trim());
|
||||
q.addEventListener('input', run); run(); setTimeout(() => q.focus(), 60);
|
||||
$('#sx').addEventListener('click', () => el.classList.remove('on'));
|
||||
};
|
||||
GUI.searchResults = function (q) {
|
||||
const res = $('#sres'); const ql = q.toLowerCase(); const W = GE.world;
|
||||
const groups = [];
|
||||
const sits = GE.SIT.list.filter((s) => !ql || (s.title + s.place).toLowerCase().includes(ql)).slice(0, ql ? 6 : 4);
|
||||
groups.push(['Situations', sits.map((s) => ({ c: GE.FAM[s.fam].color, i: GE.FAM[s.fam].icon, t: s.title, s: s.place, go: () => { $('#search').classList.remove('on'); GE.sheet.open(s.id, { fromGlobe: true }); } }))]);
|
||||
const places = [...GE.CITY_V.map((c) => ({ ...c, k: 'city' })), ...GE.AP.map((a) => ({ name: `${a.iata} · ${a.city}`, lat: a.lat, lon: a.lon, k: 'ap' }))].filter((p) => ql && p.name.toLowerCase().includes(ql)).slice(0, 5);
|
||||
groups.push(['Places', places.map((p) => ({ c: 'var(--ground)', i: p.k === 'ap' ? 'airplane' : 'location', t: p.name, s: fmt.ll(p.lat, p.lon, 2), go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: p.k === 'ap' ? 0.006 : 0.012, tilt: 0.9, heading: 0.3 }); } }))]);
|
||||
const lays = W.layers.filter((l) => ql && l.name.toLowerCase().includes(ql)).slice(0, 4);
|
||||
groups.push(['Layers', lays.map((l) => ({ c: GE.FAM[l.fam].color, i: l.icon, t: l.name, s: `${GE.FAM[l.fam].name} · ${l.on ? 'on' : 'off'} · ${fmt.n(l.count)}`, go: () => { l.on = true; bus.emit('layers'); $('#search').classList.remove('on'); GUI.openCC(l.fam); } }))]);
|
||||
if (ql.length >= 2) {
|
||||
const fl = []; for (let i = 0; i < GE.FLIGHTS.length && fl.length < 4; i++) { const f = GE.FLIGHTS[i]; if (f.cs.toLowerCase().includes(ql)) { const p = GE.flightAt(f, W.t); if (p) fl.push({ f, i, p }); } }
|
||||
groups.push(['Contacts', fl.map(({ f, i, p }) => ({ c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${p.from.iata} → ${p.to.iata}`, go: () => { $('#search').classList.remove('on'); if (APP.tab !== 'globe') APP.setTab('globe'); Cam.flyTo({ lat: p.lat, lon: p.lon, range: 0.05, tilt: 0.6 }); GUI.showPick({ kind: 'flight', id: i, title: f.cs, subtitle: `${f.type} · ${p.from.iata} → ${p.to.iata}`, lat: p.lat, lon: p.lon, altKm: p.altKm, hdg: p.hdg, meta: { from: p.from, to: p.to, f, prog: p.prog } }); } }))]);
|
||||
}
|
||||
res.innerHTML = ''; const frag = document.createDocumentFragment();
|
||||
for (const [title, items] of groups) { if (!items.length) continue; frag.append(h('div', { class: 'grpH' }, title)); for (const it of items) { const b = h('button', { class: 'it', html: `<span class="ri" style="--c:${it.c}">${ic(it.i, 'sm')}</span><span style="text-align:left;min-width:0"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.t)}</span><small>${esc(it.s)}</small></span>` }); b.addEventListener('click', it.go); frag.append(b); } }
|
||||
if (!q) frag.prepend(h('div', { class: 'grpH', style: { marginTop: 0 } }, 'Try “typhoon”, “ATL”, “Kathmandu”, “fires”, “AXR”'));
|
||||
res.append(frag);
|
||||
};
|
||||
|
||||
GUI.init = function () {
|
||||
GUI.renderDock(); GUI.initRibbon();
|
||||
bus.on('layers', () => { GUI.renderDock(); GUI.refreshCCSoft(); });
|
||||
GUI.refreshCCSoft = () => { const cc = $('#cc'); if (cc.classList.contains('on')) cc.querySelectorAll('[data-l]').forEach((b) => { const l = GE.world.layerById[b.dataset.l]; b.classList.toggle('on', l.on); const n = b.querySelector('.tc .num'); if (n) n.textContent = l.count ? fmt.n(l.count) : '—'; }); };
|
||||
$('#btnLayers').addEventListener('click', () => GUI.openCC());
|
||||
$('#btnLens').addEventListener('click', () => { const ids = LENSES.map((l) => l[0]); const i = (ids.indexOf(GE.world.lens) + 1) % ids.length; GE.world.setLens(ids[i]); APP.toast(`${ic('sparkles', 'sm')} ${LENSES[i][1]} lens`); $('#btnLens').classList.toggle('on', ids[i] !== 'cinematic'); });
|
||||
$('#btnHome').addEventListener('click', () => { GUI.showPick(null); Cam.flyTo({ range: 2.6, tilt: 0, heading: 0, shiftY: APP.isWide() ? 0 : 0.06 }); });
|
||||
$('#btnCompass').addEventListener('click', () => Cam.flyTo({ heading: 0, tilt: 0 }, { duration: 0.8 }));
|
||||
$('#status').addEventListener('click', () => GUI.snapNow());
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Ride: drop into a cockpit-follow camera on a helicopter circuit or an airliner, with a minimal HUD
|
||||
and a social reaction layer (fictional handles) — the communication aspect of watching together. */
|
||||
const RIDE = (GE.ride = { on: false });
|
||||
const CHAT_HELI = [
|
||||
['rotorwash_ret', 'I flew that exact circuit 40 times. Downwind is where your instructor goes quiet.'],
|
||||
['kora_maps', 'Watching from Lisbon. The stagefields look like a chessboard from here'],
|
||||
['skywatch_ana', 'Look at the spacing — four aircraft in the pattern and nobody bunching up'],
|
||||
['rotorwash_ret', 'Turning base now. Watch the airspeed bleed off'],
|
||||
['hydro_nerd', 'is this live?? the shadows match my window right now'],
|
||||
['geodesy_dan', 'MLAT puts them at ±15 m here. Cool to see it smooth'],
|
||||
['ais_owl', 'Came from the Suez queue thread. This is so much calmer'],
|
||||
['rotorwash_ret', 'Short final. Hover check. Then do it again. And again.'],
|
||||
];
|
||||
const CHAT_JET = [
|
||||
['skywatch_ana', 'Cruise is the best seat in the house. Look at the terminator out the left side'],
|
||||
['kora_maps', 'You can see the cloud deck the forecast promised'],
|
||||
['geodesy_dan', 'Ground speed is up — jet stream tailwind'],
|
||||
['ais_owl', 'There’s a whole convoy of ships under us right now'],
|
||||
];
|
||||
RIDE.start = function (p) {
|
||||
const W = GE.world;
|
||||
if (APP.mode === 'situation') { GE.sheet.close(true); }
|
||||
RIDE.p = p; RIDE.on = true; RIDE.yaw = 0; RIDE.pitch = 0; RIDE.zoom = 1;
|
||||
if (APP.tab !== 'globe') { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.pane .view').forEach((v) => v.classList.remove('on')); }
|
||||
APP.setMode('ride');
|
||||
W.layerById.helis.on = true;
|
||||
const isHeli = p.kind === 'heli' || p.kind === 'situation';
|
||||
const hc = isHeli ? GE.HELI_CIRCUITS[p.id || 0] : null; const fl = !isHeli ? GE.FLIGHTS[p.id] : null;
|
||||
RIDE.hc = hc; RIDE.fl = fl;
|
||||
const state = () => {
|
||||
const t = W.t;
|
||||
if (hc) { const a = GE.heliAt(hc, t), b = GE.heliAt(hc, t + 1.5); const hd = geo.bearing(a.lat, a.lon, b.lat, b.lon); const c = GE.heliAt(hc, t - 1.5); const hd0 = geo.bearing(c.lat, c.lon, a.lat, a.lon); let turn = hd - hd0; if (turn > Math.PI) turn -= TAU; if (turn < -Math.PI) turn += TAU; return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: hd, roll: clamp(turn * 6, -0.45, 0.45), spd: a.spd, vs: (b.altKm - c.altKm) / 3 * 3281 * 60 }; }
|
||||
const a = GE.flightAt(fl, t) || GE.flightAt(fl, t + 600); const b = GE.flightAt(fl, t + 4) || a;
|
||||
return { lat: a.lat, lon: a.lon, alt: a.altKm, hdg: geo.bearing(a.lat, a.lon, b.lat, b.lon), roll: 0, spd: fl.speed / 1.852, vs: (b.altKm - a.altKm) / 4 * 3281 * 60 };
|
||||
};
|
||||
RIDE.state = state;
|
||||
const rig = {
|
||||
blendTime: 1.8,
|
||||
pose() { const s = state(); RIDE.s = s; const pitch = (hc ? -0.16 : -0.05) + RIDE.pitch; return Cam.pose(s.lat, s.lon, s.alt + (hc ? 0.003 : 0.002), s.hdg + RIDE.yaw, pitch, s.roll * 0.6, (hc ? 64 : 58) * D2R / RIDE.zoom); },
|
||||
look(dx, dy) { RIDE.yaw -= dx * 0.004; RIDE.pitch = clamp(RIDE.pitch - dy * 0.003, -0.9, 0.5); },
|
||||
zoom(r) { RIDE.zoom = clamp(RIDE.zoom / r, 0.6, 3); },
|
||||
};
|
||||
Cam.setRig(rig);
|
||||
RIDE.renderUI(isHeli);
|
||||
RIDE.chatI = 0; clearInterval(RIDE._ci); RIDE._ci = setInterval(() => RIDE.nextChat(), 2600); setTimeout(() => RIDE.nextChat(), 900);
|
||||
clearInterval(RIDE._ri); RIDE._ri = setInterval(() => { if (Math.random() < 0.7) RIDE.heart(); }, 700);
|
||||
haptic([10, 40, 10]);
|
||||
};
|
||||
RIDE.renderUI = function (isHeli) {
|
||||
const el = $('#ride'); const nm = isHeli ? RIDE.hc.cs : RIDE.fl.cs; const tp = isHeli ? `${RIDE.hc.type} · ${RIDE.hc.field || 'orbit'} circuit` : `${RIDE.fl.type} · ${GE.flightAt(RIDE.fl, GE.world.t)?.from.iata} → ${GE.flightAt(RIDE.fl, GE.world.t)?.to.iata}`;
|
||||
el.innerHTML = `<div class="hud" id="hud"><svg id="hudSvg"></svg><div class="tape" style="left:14px" id="hSpd"></div><div class="tape" style="right:14px;text-align:right" id="hAlt"></div></div>
|
||||
<div class="top"><button class="circ glass" id="rideX" aria-label="Exit ride">${ic('chevd')}</button><div class="who glass"><span class="badge live" style="background:rgba(255,69,58,.2)"><span class="dot pulse"></span>Ride</span><span style="min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis"><b>${esc(nm)}</b> · ${esc(tp)}</span><span style="margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--text2)">${ic('people', 'sm')}<span class="num" id="rideN">2,318</span></span></div></div>
|
||||
<div class="chat" id="chat"></div><div class="react" id="react"></div>
|
||||
<div class="compose"><div class="in glass"><input id="rideIn" placeholder="Say something to everyone riding…" enterkeyhint="send" maxlength="120"></div><button class="circ glass" id="rideHeart" aria-label="React" style="width:48px;height:48px;color:var(--live)">${ic('heartf')}</button></div>`;
|
||||
el.classList.add('on');
|
||||
$('#rideX').addEventListener('click', () => RIDE.exit());
|
||||
$('#rideHeart').addEventListener('click', () => { for (let i = 0; i < 4; i++) setTimeout(RIDE.heart, i * 90); haptic(6); });
|
||||
$('#rideIn').addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.value.trim()) { RIDE.bubble('you', e.target.value.trim(), true); e.target.value = ''; } });
|
||||
};
|
||||
RIDE.bubble = function (who, text, mine) {
|
||||
const p = Object.values(GE.PEOPLE).find((x) => x.h === who) || GE.PEOPLE.you;
|
||||
const c = $('#chat'); if (!c) return;
|
||||
const b = h('div', { class: 'bub' }); b.innerHTML = `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span><div class="bx glass" ${mine ? 'style="background:rgba(124,196,255,.22)"' : ''}><b>@${esc(p.h)}${who === 'rotorwash_ret' ? ' · former instructor' : ''}</b>${esc(text)}</div>`;
|
||||
c.append(b); while (c.children.length > 6) c.firstChild.remove();
|
||||
};
|
||||
RIDE.nextChat = function () { const L = RIDE.hc ? CHAT_HELI : CHAT_JET; const [w, t] = L[RIDE.chatI++ % L.length]; RIDE.bubble(w, t); const n = $('#rideN'); if (n) n.textContent = fmt.n(2318 + Math.round(Math.sin(Date.now() / 4000) * 40 + RIDE.chatI * 7)); };
|
||||
RIDE.heart = function () { const r = $('#react'); if (!r || prefersReduced()) return; const s = h('span'); s.innerHTML = ic('heartf'); s.style.setProperty('--dx', (Math.random() * 40 - 20) + 'px'); s.style.left = (Math.random() * 16) + 'px'; s.style.color = ['#FF453A', '#FF9F0A', '#FFD166', '#7CC4FF'][Math.floor(Math.random() * 4)]; r.append(s); setTimeout(() => s.remove(), 2700); };
|
||||
RIDE.frame = function (view) {
|
||||
if (!RIDE.on || !RIDE.s) return; const s = RIDE.s; const svg = $('#hudSvg'); if (!svg) return;
|
||||
const w = svg.clientWidth, hh = svg.clientHeight; const cx = w / 2, cy = hh * 0.42;
|
||||
const hdg = (((s.hdg + RIDE.yaw) * R2D) % 360 + 360) % 360; const roll = -s.roll * 0.6 * R2D; const pitchPx = (RIDE.pitch + (RIDE.hc ? -0.16 : -0.05)) * R2D * 6;
|
||||
let g = `<g transform="rotate(${roll.toFixed(2)} ${cx} ${cy})" stroke="rgba(190,255,200,.8)" fill="rgba(190,255,200,.85)" font-size="10" font-family="var(--mono)">`;
|
||||
g += `<line x1="${cx - 150}" y1="${cy + pitchPx}" x2="${cx - 40}" y2="${cy + pitchPx}" stroke-width="1.5"/><line x1="${cx + 40}" y1="${cy + pitchPx}" x2="${cx + 150}" y2="${cy + pitchPx}" stroke-width="1.5"/>`;
|
||||
for (const pd of [-10, -5, 5, 10]) { const y = cy + pitchPx - pd * 6; g += `<line x1="${cx - 50}" y1="${y}" x2="${cx - 26}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><line x1="${cx + 26}" y1="${y}" x2="${cx + 50}" y2="${y}" stroke-width="1" ${pd < 0 ? 'stroke-dasharray="4 3"' : ''}/><text x="${cx + 56}" y="${y + 3}" stroke="none">${pd}</text>`; }
|
||||
g += `</g>`;
|
||||
g += `<g stroke="rgba(190,255,200,.95)" fill="none" stroke-width="1.6"><circle cx="${cx}" cy="${cy}" r="5"/><line x1="${cx - 18}" y1="${cy}" x2="${cx - 5}" y2="${cy}"/><line x1="${cx + 5}" y1="${cy}" x2="${cx + 18}" y2="${cy}"/><line x1="${cx}" y1="${cy - 5}" x2="${cx}" y2="${cy - 12}"/></g>`;
|
||||
// heading tape
|
||||
const ty = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sat')) + 70;
|
||||
g += `<g font-size="11" font-family="var(--mono)" fill="rgba(190,255,200,.85)" stroke="rgba(190,255,200,.6)" text-anchor="middle">`;
|
||||
for (let d = Math.floor(hdg / 5) * 5 - 40; d <= hdg + 40; d += 5) { const x = cx + (d - hdg) * 4; const dd = ((d % 360) + 360) % 360; g += `<line x1="${x}" y1="${ty}" x2="${x}" y2="${ty + (dd % 10 === 0 ? 9 : 5)}"/>`; if (dd % 30 === 0) g += `<text x="${x}" y="${ty + 22}" stroke="none">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? String(dd / 10).padStart(2, '0')}</text>`; }
|
||||
g += `<rect x="${cx - 24}" y="${ty - 22}" width="48" height="18" rx="4" fill="rgba(0,20,8,.35)"/><text x="${cx}" y="${ty - 9}" stroke="none" font-weight="700">${String(Math.round(hdg)).padStart(3, '0')}</text></g>`;
|
||||
svg.innerHTML = g;
|
||||
$('#hSpd').innerHTML = `${Math.round(s.spd)}<small>KT GS</small>`;
|
||||
$('#hAlt').innerHTML = `${fmt.n(Math.round(s.alt * 3281 / 10) * 10)}<small>FT ${RIDE.hc ? 'AGL' : 'MSL'} · ${s.vs > 0 ? '+' : ''}${Math.round(s.vs / 50) * 50} FPM</small>`;
|
||||
};
|
||||
RIDE.exit = function (silent) {
|
||||
if (!RIDE.on) return; RIDE.on = false; clearInterval(RIDE._ci); clearInterval(RIDE._ri);
|
||||
$('#ride').classList.remove('on'); $('#ride').innerHTML = '';
|
||||
const v = Cam.view(400, 800); const [la, lo] = geo.ll(v.eye);
|
||||
Cam.setRig(null); Cam.set({ lat: la, lon: lo, range: Math.max(0.002, v3.len(v.eye) - 1 + 0.002), tilt: 0.9, heading: RIDE.s ? RIDE.s.hdg : 0, shiftY: 0.06 });
|
||||
APP.setMode('');
|
||||
if (!silent) Cam.flyTo({ range: 0.012, tilt: 0.8 });
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Capture: Look (AR — the God's-eye view projected around you on a rendered "camera" scene with
|
||||
aircraft/satellite callouts, compass tape, horizon; drag to look) and Witness (shutter → sealed
|
||||
bundle: real SHA-256 over the rendered frame, real ECDSA P-256 via WebCrypto, simulated IMU/GPS,
|
||||
hash chain, gold seal; then drop it as a hologram here). */
|
||||
const CAP = (GE.capture = { mode: 'look', yaw: 0, pitch: 0.06, zoom: 1, on: false });
|
||||
GE.captures = [];
|
||||
const SPOT = { lat: 41.8662, lon: -87.6072, alt: 0.042, name: 'Chicago lakefront', hdg: -52 * D2R };
|
||||
CAP.spot = SPOT;
|
||||
|
||||
/* ---------- device key (non-extractable, IndexedDB when available) ---------- */
|
||||
const KEY = { pair: null, jwk: null, ready: null };
|
||||
function idb() { return new Promise((res, rej) => { try { const r = indexedDB.open('ge-vision', 1); r.onupgradeneeded = () => r.result.createObjectStore('keys'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); } catch (e) { rej(e); } }); }
|
||||
KEY.get = function () {
|
||||
if (KEY.ready) return KEY.ready;
|
||||
KEY.ready = (async () => {
|
||||
if (!hasSubtle()) return null;
|
||||
try { const db = await idb(); const got = await new Promise((res) => { const tx = db.transaction('keys', 'readonly'); const rq = tx.objectStore('keys').get('device'); rq.onsuccess = () => res(rq.result); rq.onerror = () => res(null); }); if (got && got.privateKey) { KEY.pair = got; } } catch {}
|
||||
if (!KEY.pair) {
|
||||
try {
|
||||
KEY.pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
|
||||
try { const db = await idb(); await new Promise((res) => { const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').put(KEY.pair, 'device'); tx.oncomplete = res; tx.onerror = res; }); } catch {}
|
||||
} catch (e) { console.warn('keygen', e); return null; }
|
||||
}
|
||||
try { KEY.jwk = await crypto.subtle.exportKey('jwk', KEY.pair.publicKey); } catch {}
|
||||
return KEY.pair;
|
||||
})();
|
||||
return KEY.ready;
|
||||
};
|
||||
const hexToBytes = (hx) => new Uint8Array(hx.match(/../g).map((b) => parseInt(b, 16)));
|
||||
async function signHex(hashHex) { const k = await KEY.get(); if (!k) return null; const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, k.privateKey, hexToBytes(hashHex)); return fmt.hex(sig); }
|
||||
async function verifyHex(hashHex, sigHex) { const k = await KEY.get(); if (!k || !sigHex) return false; try { return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, k.publicKey, hexToBytes(sigHex), hexToBytes(hashHex)); } catch { return false; } }
|
||||
GE.crypto = { KEY, signHex, verifyHex };
|
||||
|
||||
/* ---------- simulated golden hour at the spot ---------- */
|
||||
CAP.goldenMs = function () {
|
||||
const base = Date.now(); let best = base, bd = 1e9;
|
||||
for (let m = -24 * 60; m <= 24 * 60; m += 4) { const ms = base + m * 60000; const e = astro.elev(ms, SPOT.lat, SPOT.lon); const e2 = astro.elev(ms + 60000, SPOT.lat, SPOT.lon); if (e2 < e) { const d = Math.abs(e - 3.2); if (d < bd) { bd = d; best = ms; } } }
|
||||
return best;
|
||||
};
|
||||
|
||||
/* ---------- enter / exit ---------- */
|
||||
CAP.enter = function () {
|
||||
CAP.on = true; CAP.ms = CAP.goldenMs(); CAP.t = (CAP.ms - NOW) / 1000; CAP.sun = astro.sun(CAP.ms);
|
||||
// face the sunset over the skyline
|
||||
const sunAz = geo.bearing(SPOT.lat, SPOT.lon, CAP.sun.lat, CAP.sun.lon);
|
||||
CAP.yaw = 0; SPOT.hdg = sunAz * 0.35 + (-52 * D2R) * 0.65;
|
||||
GE.renderOverride = { t: CAP.t, sun: CAP.sun, simMs: CAP.ms, offsetH: (CAP.ms - Date.now()) / 3600000, exposure: 1.25, cloudK: 0.5 };
|
||||
Cam.setRig({ blendTime: 0.01, pose: () => Cam.pose(SPOT.lat, SPOT.lon, SPOT.alt, SPOT.hdg + CAP.yaw, CAP.pitch, 0, 62 * D2R / CAP.zoom), look(dx, dy) { CAP.yaw -= dx * 0.0042 / CAP.zoom; CAP.pitch = clamp(CAP.pitch - dy * 0.0035 / CAP.zoom, -0.5, 1.2); }, zoom(r) { CAP.zoom = clamp(CAP.zoom / r, 0.6, 4); CAP.syncZoom(); } });
|
||||
Cam.rigBlend = 1;
|
||||
GE.scenes.ensure(GE.scenes.byId.chi);
|
||||
CAP.render();
|
||||
KEY.get();
|
||||
};
|
||||
CAP.exit = function () { if (!CAP.on) return; CAP.on = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); };
|
||||
CAP.render = function () {
|
||||
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1];
|
||||
el.innerHTML = `<div class="cap-ui">
|
||||
<svg class="compass-tape" id="ctape"></svg>
|
||||
<div class="callouts" id="callouts"></div>
|
||||
<div class="cap-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}</span></div>
|
||||
<div class="sensors glass ${CAP.mode === 'witness' ? 'on' : ''}" id="sensors"></div>
|
||||
<div class="cap-bottom">
|
||||
<div class="zooms glass">${[0.5, 1, 3].map((z) => `<button data-z="${z}" class="${CAP.zoom === z ? 'on' : ''}">${z === 0.5 ? '.5' : z}×</button>`).join('')}</div>
|
||||
<div class="dial" id="dial"><button data-m="look" class="${CAP.mode === 'look' ? 'on' : ''}">Look</button><button data-m="witness" class="${CAP.mode === 'witness' ? 'on' : ''}">Witness</button></div>
|
||||
<div class="shutrow"><button class="lastcap" id="lastcap" aria-label="Last capture" style="${last ? `background-image:url(${last.thumb})` : ''}">${last ? '<svg class="seal"><use href="#i-seal"/></svg>' : ''}</button>
|
||||
<button class="shutter ${CAP.mode === 'look' ? 'look' : ''}" id="shutter" aria-label="${CAP.mode === 'look' ? 'Switch to Witness' : 'Capture and sign'}"><i></i></button>
|
||||
<button class="circ glass" id="capFlip" aria-label="Layers in view">${ic('layers')}</button></div>
|
||||
</div></div>`;
|
||||
el.querySelectorAll('[data-z]').forEach((b) => b.addEventListener('click', () => { CAP.zoom = +b.dataset.z; CAP.syncZoom(); haptic(4); }));
|
||||
el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.mode = b.dataset.m; CAP.render(); haptic(6); }));
|
||||
$('#shutter').addEventListener('click', () => { if (CAP.mode === 'look') { CAP.mode = 'witness'; CAP.render(); return; } CAP.shoot(); });
|
||||
$('#lastcap').addEventListener('click', () => { if (GE.captures.length) GE.you.inspect(GE.captures.length - 1); });
|
||||
$('#capFlip').addEventListener('click', () => APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`));
|
||||
};
|
||||
CAP.syncZoom = () => $$('#v-capture [data-z]').forEach((x) => x.classList.toggle('on', +x.dataset.z === CAP.zoom));
|
||||
|
||||
/* ---------- per-frame AR overlay ---------- */
|
||||
CAP.frame = function (view) {
|
||||
if (!CAP.on) return;
|
||||
const W = GE.world; const t = CAP.t + (performance.now() / 1000 - (CAP._t0 || (CAP._t0 = performance.now() / 1000)));
|
||||
const host = $('#callouts'); if (!host) return;
|
||||
// compass tape
|
||||
const svg = $('#ctape'); const w = svg.clientWidth; const hdg = (((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360;
|
||||
const pxPerDeg = w / (view.fovY * R2D * view.aspect);
|
||||
let g = `<g font-family="var(--font)" font-size="11" text-anchor="middle" fill="rgba(255,255,255,.85)" stroke="rgba(255,255,255,.55)">`;
|
||||
for (let d = Math.floor((hdg - 60) / 5) * 5; d <= hdg + 60; d += 5) { const x = w / 2 + (d - hdg) * pxPerDeg; if (x < -10 || x > w + 10) continue; const dd = ((d % 360) + 360) % 360; g += `<line x1="${x}" x2="${x}" y1="2" y2="${dd % 15 === 0 ? 11 : 6}"/>`; if (dd % 15 === 0) g += `<text x="${x}" y="25" stroke="none" font-weight="${dd % 90 === 0 ? 800 : 500}" fill="${dd === 0 ? '#FF6A3D' : 'rgba(255,255,255,.8)'}">${{ 0: 'N', 90: 'E', 180: 'S', 270: 'W' }[dd] ?? dd}</text>`; }
|
||||
g += `<path d="M${w / 2 - 5} 0 L${w / 2 + 5} 0 L${w / 2} 6Z" fill="#FFD60A" stroke="none"/><text x="${w / 2}" y="38" font-size="10" stroke="none" fill="rgba(255,255,255,.6)">${Math.round(hdg)}° · pitch ${Math.round(CAP.pitch * R2D)}°</text></g>`;
|
||||
svg.innerHTML = g;
|
||||
// contacts
|
||||
const want = [];
|
||||
const sun = CAP.sun;
|
||||
for (let i = 0; i < GE.FLIGHTS.length; i++) {
|
||||
const f = GE.FLIGHTS[i]; const p = GE.flightAt(f, CAP.t); if (!p) continue;
|
||||
const dkm = geo.dist(SPOT.lat, SPOT.lon, p.lat, p.lon) * EARTH_KM; if (dkm > 110) continue;
|
||||
const v = v3.scale(p.v, 1 + p.altKm / EARTH_KM); want.push({ k: 'f' + i, v, c: 'var(--sky)', i: 'airplane', t: f.cs, s: `${f.type} · ${fmt.n(Math.round(p.altKm * 3281 / 100) * 100)} ft · ${dkm.toFixed(0)} km`, d: dkm });
|
||||
}
|
||||
const obs = geo.vec(SPOT.lat, SPOT.lon); const up = v3.norm(obs);
|
||||
if (!CAP._sats || Math.abs(CAP._satT - CAP.t) > 20) {
|
||||
CAP._sats = []; CAP._satT = CAP.t;
|
||||
for (let i = 0; i < GE.SATS.length; i += 3) { const s = GE.SATS[i]; if (s.kind > 1 && s.kind !== 6 && s.kind !== 5) continue; const p = GE.satAt(s, CAP.t, sun.gmst); const d = v3.sub(p, obs); const el = Math.asin(v3.dot(v3.norm(d), up)) * R2D; if (el > 12) CAP._sats.push({ s, el, km: v3.len(d) * EARTH_KM }); }
|
||||
CAP._sats.sort((a, b) => a.km - b.km); CAP._sats = CAP._sats.slice(0, 3);
|
||||
}
|
||||
for (const o of CAP._sats) { const p = GE.satAt(o.s, CAP.t, sun.gmst); want.push({ k: 's' + o.s.name, v: p, c: 'var(--orbit)', i: 'satellite', t: o.s.name, s: `${Math.round(o.s.alt)} km up · ${Math.round(o.el)}° elev`, d: o.km }); }
|
||||
const seen = new Set(); let n = 0;
|
||||
for (const it of want.sort((a, b) => a.d - b.d)) {
|
||||
const s = W.project(view, it.v); let el = host.querySelector(`[data-k="${it.k}"]`);
|
||||
const onScreen = s && s.x > 8 && s.x < view.w - 8 && s.y > 60 && s.y < view.h - 230;
|
||||
if (!onScreen || n >= 7) { if (el) el.remove(); continue; }
|
||||
n++; seen.add(it.k);
|
||||
if (!el) { el = h('div', { 'data-k': it.k }); el.innerHTML = `<div class="reticle" style="--c:${it.c}"></div><div class="callout glass" style="--c:${it.c}"><span class="ci">${ic(it.i, 'sm')}</span><span><b>${esc(it.t)}</b><span data-s></span></span></div>`; host.append(el); }
|
||||
el.querySelector('[data-s]').textContent = it.s;
|
||||
el.querySelector('.reticle').style.transform = `translate(${s.x}px, ${s.y}px)`;
|
||||
el.querySelector('.callout').style.transform = `translate(${s.x + 24}px, ${s.y}px) translate(0, -50%)`;
|
||||
}
|
||||
host.querySelectorAll('[data-k]').forEach((e) => { if (!seen.has(e.dataset.k)) e.remove(); });
|
||||
// witness sensors
|
||||
if (CAP.mode === 'witness') {
|
||||
const ss = $('#sensors'); const tt = performance.now() / 1000;
|
||||
const imu = Array.from({ length: 40 }, (_, i) => Math.sin((tt * 3 + i * 0.4)) * 0.4 + Math.sin(tt * 7.3 + i) * 0.15);
|
||||
ss.innerHTML = `<div style="display:flex;justify-content:space-between"><span>IMU 50 Hz</span><b>${(imu[39] * 0.12).toFixed(3)} g</b></div><svg viewBox="0 0 140 26">${GE.sparkPath(imu, 140, 26, '#5CF2A0', 1.3)}</svg><div style="display:flex;justify-content:space-between"><span>GPS</span><b>±4 m · ${SPOT.lat.toFixed(5)}, ${SPOT.lon.toFixed(5)}</b></div><div style="display:flex;justify-content:space-between"><span>Heading</span><b>${Math.round(hdg)}° true</b></div><div style="display:flex;justify-content:space-between;gap:10px"><span>Key</span><b>P-256 · ${KEY.pair ? 'device-bound' : '…'}</b></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- shoot & seal ---------- */
|
||||
CAP.shoot = async function () {
|
||||
if (CAP.busy) return; CAP.busy = true; haptic([5, 20, 30]);
|
||||
const flash = h('div', { style: { position: 'absolute', inset: 0, background: '#fff', zIndex: 72, opacity: 1, transition: 'opacity .45s ease' } }); $('#app').append(flash); requestAnimationFrame(() => (flash.style.opacity = 0)); setTimeout(() => flash.remove(), 500);
|
||||
const start = Date.now();
|
||||
// render the frame offscreen at 1200×900
|
||||
const W0 = 1200, H0 = 900; const view = Cam.view(W0, H0);
|
||||
const cv = GE.thumbs.renderView(view, W0, H0, { ...GE.renderOverride, px: 1.6 });
|
||||
const blob = await new Promise((res) => cv.toBlob(res, 'image/jpeg', 0.9));
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const thumb = (() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; c.getContext('2d').drawImage(cv, 0, 0, 320, 240); return c.toDataURL('image/jpeg', 0.8); })();
|
||||
const hdgDeg = Math.round((((SPOT.hdg + CAP.yaw) * R2D) % 360 + 360) % 360);
|
||||
const imu = Array.from({ length: 150 }, (_, i) => ({ t: i * 20, a: +(hdgDeg + Math.sin(i / 9) * 0.6).toFixed(2), b: +(CAP.pitch * R2D + Math.sin(i / 7) * 0.3).toFixed(2), g: +(Math.sin(i / 5) * 0.4).toFixed(2), ax: +(Math.sin(i / 3) * 0.02).toFixed(3), ay: +(9.806 + Math.sin(i / 4) * 0.03).toFixed(3), az: +(Math.cos(i / 3) * 0.02).toFixed(3) }));
|
||||
const session = { start, end: start + 3000, imu, gps: [0, 1, 2].map((k) => ({ t: start + k * 1000, lat: +(SPOT.lat + k * 1e-6).toFixed(6), lon: +SPOT.lon.toFixed(6), acc: 4, alt: 182 })), heading: hdgDeg };
|
||||
const context = { device: 'Simulated camera (concept)', ua: navigator.userAgent.slice(0, 64), screen: `${screen.width}x${screen.height}@${Math.round((devicePixelRatio || 1) * 100) / 100}`, timezone: GE.ME.tz, networkTimeOffsetMs: 12 };
|
||||
const prev = GE.captures.length ? GE.captures[GE.captures.length - 1].hash : '0'.repeat(64);
|
||||
const b = { bytes, thumb, session, context, prev, place: SPOT.name, lat: SPOT.lat, lon: SPOT.lon, at: start };
|
||||
CAP.ceremony(b, cv);
|
||||
b.mediaHash = await sha256(bytes);
|
||||
b.hash = await sha256(canon({ media: b.mediaHash, session, context, prev }));
|
||||
b.sig = await signHex(b.hash);
|
||||
b.jwk = KEY.jwk;
|
||||
GE.captures.push(b);
|
||||
CAP.ceremonyFill(b);
|
||||
CAP.busy = false;
|
||||
};
|
||||
CAP.ceremony = function (b, cv) {
|
||||
const ov = $('#sealOv');
|
||||
ov.innerHTML = `<div class="bundle glass strong"><div class="ph" id="bph"><svg class="bigseal" id="bigseal" viewBox="0 0 24 24"><use href="#i-seal"/></svg></div>
|
||||
<div class="steps">
|
||||
<div class="step" data-s="0"><span class="ck">${ic('check', 'xs')}</span><div><b>Frame hashed</b> · SHA-256 of ${fmt.n(b.bytes.length)} JPEG bytes<div class="h" id="sh0">…</div></div></div>
|
||||
<div class="step" data-s="1"><span class="ck">${ic('check', 'xs')}</span><div><b>Session sealed</b> · IMU 150 samples @ 50 Hz · GPS ±4 m · heading ${b.session.heading}°<svg viewBox="0 0 300 30" preserveAspectRatio="none">${GE.sparkPath(b.session.imu.map((x) => x.g + x.b * 0.02), 300, 30, '#5CF2A0', 1.2)}</svg></div></div>
|
||||
<div class="step" data-s="2"><span class="ck">${ic('check', 'xs')}</span><div><b>Chained</b> to this device’s previous bundle<div class="h">prev ${b.prev.slice(0, 24)}…</div></div></div>
|
||||
<div class="step" data-s="3"><span class="ck">${ic('check', 'xs')}</span><div><b>Bundle hash</b><div class="h" id="sh3">…</div></div></div>
|
||||
<div class="step" data-s="4"><span class="ck">${ic('check', 'xs')}</span><div><b>Signed</b> · ECDSA P-256, key generated on device, non-extractable<div class="h" id="sh4">…</div></div></div>
|
||||
</div>
|
||||
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('cube', 'sm')}Drop as hologram here</button></div>
|
||||
<div class="btnrow" id="bbtns2" style="opacity:.35;pointer-events:none"><button class="btn" id="bver">${ic('check', 'sm')}Verify</button><button class="btn" id="bdone">Done</button></div>
|
||||
<p style="margin:0;font-size:11px;color:var(--text3);text-align:center">None of this proves truth. It raises the cost of lying.</p></div>`;
|
||||
const ph = $('#bph'); const c2 = document.createElement('canvas'); c2.width = 600; c2.height = 450; c2.getContext('2d').drawImage(cv, 0, 0, 600, 450); ph.prepend(c2);
|
||||
ov.classList.add('on');
|
||||
};
|
||||
CAP.ceremonyFill = async function (b) {
|
||||
const step = (i) => new Promise((r) => setTimeout(() => { const s = $(`.step[data-s="${i}"]`); s && s.classList.add('on', 'done'); haptic(4); r(); }, prefersReduced() ? 30 : 380));
|
||||
const stream = async (id, hex) => { const el = $('#' + id); if (!el) return; if (prefersReduced()) { el.textContent = hex; return; } for (let i = 0; i <= hex.length; i += 4) { el.textContent = hex.slice(0, i) + (i < hex.length ? '▍' : ''); await new Promise((r) => setTimeout(r, 8)); } };
|
||||
$(`.step[data-s="0"]`).classList.add('on'); await stream('sh0', b.mediaHash); await step(0);
|
||||
await step(1); await step(2);
|
||||
$(`.step[data-s="3"]`).classList.add('on'); await stream('sh3', b.hash); await step(3);
|
||||
$(`.step[data-s="4"]`).classList.add('on'); await stream('sh4', b.sig ? b.sig : 'WebCrypto unavailable here — bundle hashed but unsigned'); await step(4);
|
||||
$('#bigseal').classList.add('on'); haptic([10, 40, 20]);
|
||||
for (const id of ['bbtns', 'bbtns2']) { const e = $('#' + id); e.style.opacity = 1; e.style.pointerEvents = 'auto'; }
|
||||
$('#bdrop').addEventListener('click', () => { GE.world.userHolos.push({ lat: b.lat, lon: b.lon, title: 'Your capture · ' + b.place, by: 'you', at: b.at }); b.dropped = true; $('#sealOv').classList.remove('on'); APP.toast(`${ic('cube', 'sm')} Hologram dropped · visible to people here`); CAP.render(); GE.you.render(); });
|
||||
$('#bver').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.you.inspect(GE.captures.indexOf(b)); });
|
||||
$('#bdone').addEventListener('click', () => { $('#sealOv').classList.remove('on'); CAP.render(); GE.you.render(); });
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
/* Threads: reconstructions people build together. Three strata — Observations (immutable, hashed,
|
||||
sourced), Hypotheses (person/AI, confidence, citations, agree/dispute), Narrative (camera path through
|
||||
space-time synced to the ribbon) — plus a real SHA-256 hash-chain ledger you can verify and tamper
|
||||
with, and an AI refine composer whose output is recorded as a *proposed* hypothesis. */
|
||||
const THR = (GE.threads = { ws: null });
|
||||
const P = GE.PEOPLE;
|
||||
const KT = GE.scenes.FLOOD.startMs; const HR_ = 3600000, MIN = 60000;
|
||||
const KO = GE.scenes.KOSHI;
|
||||
const THREADS = {
|
||||
koshi: {
|
||||
id: 'koshi', sit: 'koshi', title: 'Bhote Koshi outburst flood', place: 'Rasuwagadhi → Syaphrubesi, Nepal', date: '26 Aug 2026',
|
||||
window: { start: KT - 1.5 * HR_, end: KT + 6 * HR_ }, people: ['hydro', 'kora', 'tamang', 'geo', 'sky', 'ais'],
|
||||
cam: { lat: 28.205, lon: 85.352, range: 0.0042, heading: 0.32, tilt: 1.02 },
|
||||
obs: [
|
||||
{ id: 'O1', i: 'frame', f: 'infra', t: 'Optical satellite tile — before', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 24, 4, 52), meta: ['10 m', 'cloud 6%'] },
|
||||
{ id: 'O2', i: 'frame', f: 'infra', t: 'Optical satellite tile — after: new scour along 9.4 km of channel', src: 'Public 10 m optical archive', at: Date.UTC(2026, 7, 27, 4, 58), meta: ['10 m', 'cloud 12%'] },
|
||||
{ id: 'O3', i: 'drop', f: 'haz', t: 'River gauge, Syaphrubesi: stage 3.1 → 7.8 m', src: 'Gauge feed (illustrative values)', at: KT + 1.75 * HR_, meta: ['15-min series', 'peak 09:30 UTC'] },
|
||||
{ id: 'O4', i: 'wave', f: 'haz', t: 'No M ≥ 3 earthquake within 50 km in the prior 24 h', src: 'Seismic catalog', at: KT, meta: ['rules out seismic trigger'] },
|
||||
{ id: 'O5', i: 'seal', f: 'eyes', t: 'Signed capture — water reaches the road at Timure', src: '@tamang_lens · illustrative', at: KT + 27 * MIN, meta: ['IMU ✓', 'GPS ±6 m', 'heading 214°'], lat: 28.2551, lon: 85.3712 },
|
||||
{ id: 'O6', i: 'seal', f: 'eyes', t: 'Signed capture — debris against the Syaphrubesi bridge deck', src: '@hydro_nerd · illustrative', at: KT + 61 * MIN, meta: ['IMU ✓', 'GPS ±4 m', 'heading 352°'], lat: 28.1629, lon: 85.3402 },
|
||||
{ id: 'O7', i: 'seal', f: 'eyes', t: 'Signed capture — fresh scour line at Rasuwagadhi', src: '@geodesy_dan · illustrative', at: KT + 21.5 * HR_, meta: ['IMU ✓', 'GPS ±5 m'], lat: 28.2802, lon: 85.3797 },
|
||||
{ id: 'O8', i: 'helicopter', f: 'sky', t: 'ADS-B: rescue helicopter, 3 sorties along the valley', src: 'ADS-B/MLAT (illustrative track)', at: KT + 2.3 * HR_, meta: ['10:05–13:40 UTC'] },
|
||||
{ id: 'O9', i: 'cloud', f: 'wx', t: 'Upstream rain gauge: 4 mm in 24 h', src: 'Met station (illustrative)', at: KT - 1 * HR_, meta: ['not a rain-driven flood'] },
|
||||
{ id: 'O10', i: 'frame', f: 'infra', t: 'Glacial lake area change upstream: −0.42 km²', src: 'Derived from O1/O2-class imagery (illustrative)', at: Date.UTC(2026, 7, 27, 5, 0), meta: ['outside border, upstream'] },
|
||||
],
|
||||
hyps: [
|
||||
{ id: 'H1', by: 'hydro', text: 'A glacial-lake outburst upstream — not rainfall — triggered the flood.', conf: 0.82, ci: [0.71, 0.9], cites: ['O4', 'O9', 'O10'], agree: 31, dispute: 2, status: 'supported' },
|
||||
{ id: 'H2', by: 'kora', text: 'The crest passed Timure about 08:12 UTC and reached Syaphrubesi ~35 min later (≈ 5 m/s).', conf: 0.68, ci: [0.55, 0.79], cites: ['O3', 'O5', 'O6'], agree: 18, dispute: 4, status: 'supported' },
|
||||
{ id: 'H3', by: 'tamang', text: 'Clip O5 was filmed from the north bank, about 140 m upstream of the Timure bridge.', conf: 0.74, ci: [0.6, 0.84], cites: ['O5'], agree: 12, dispute: 1, status: 'supported' },
|
||||
{ id: 'H4', by: 'ai', ai: true, text: 'Peak stage at Syaphrubesi lagged the visible crest by ~20 min — consistent with debris damming at the bridge.', conf: 0.41, ci: [0.22, 0.6], cites: ['O3', 'O6'], agree: 3, dispute: 5, status: 'disputed', prompt: 'Why does the gauge peak late?' },
|
||||
],
|
||||
beats: [
|
||||
{ at: KT, t: '07:45', text: 'Outburst upstream (inferred). The valley is quiet; the river is at its normal 3 m stage.', cites: ['H1', 'O4', 'O9'], cam: { lat: 28.262, lon: 85.372, range: 0.0032, heading: 3.4, tilt: 1.05 } },
|
||||
{ at: KT + 20 * MIN, t: '08:05', text: 'The crest reaches Rasuwagadhi and the border bridge.', cites: ['H2', 'O7'], cam: { lat: 28.272, lon: 85.37, range: 0.0018, heading: 0.6, tilt: 1.12 } },
|
||||
{ at: KT + 27 * MIN, t: '08:12', text: 'A witness on the north bank at Timure films water reaching the road.', cites: ['H3', 'O5'], cam: { lat: 28.247, lon: 85.364, range: 0.0016, heading: 0.35, tilt: 1.15 } },
|
||||
{ at: KT + 61 * MIN, t: '08:46', text: 'Debris piles against the Syaphrubesi bridge deck.', cites: ['O6', 'H4'], cam: { lat: 28.152, lon: 85.336, range: 0.0016, heading: 0.2, tilt: 1.12 } },
|
||||
{ at: KT + 105 * MIN, t: '09:30', text: 'Gauge peaks at 7.8 m — about 20 minutes after the visible crest.', cites: ['O3', 'H4'], cam: { lat: 28.15, lon: 85.33, range: 0.0026, heading: 0.9, tilt: 1.05 } },
|
||||
{ at: KT + 140 * MIN, t: '10:05', text: 'First rescue sortie climbs the valley.', cites: ['O8'], cam: { lat: 28.19, lon: 85.345, range: 0.0045, heading: 0.3, tilt: 1.0 } },
|
||||
],
|
||||
suggestions: ['Align the witness clip to the corridor using the bridge as a landmark', 'Estimate crest speed between Timure and Syaphrubesi', 'What would falsify H1?'],
|
||||
fallback: 'Using the Syaphrubesi bridge as a fixed landmark, clip [O6] was taken from the east abutment looking upstream; matching its horizon to the IMU pitch places the crest front 85 ± 20 m upstream of the bridge at 08:46 UTC. That timing agrees with the gauge [O3] if the crest travelled 4.6 ± 0.6 m/s from Timure [O5].\nConfidence: 0.63',
|
||||
},
|
||||
concert: {
|
||||
id: 'concert', sit: 'concert', title: 'Stadium encore in 4D', place: 'Wembley, London', date: 'last night',
|
||||
window: { start: Date.now() - 21 * HR_, end: Date.now() - 19.5 * HR_ }, people: ['crowd', 'sky', 'kora', 'geo'],
|
||||
cam: { lat: 51.5546, lon: -0.2822, range: 0.00042, heading: 0.6, tilt: 1.1 },
|
||||
obs: [
|
||||
{ id: 'O1', i: 'seal', f: 'eyes', t: '2,311 signed phone captures from 86 seat blocks', src: 'Signed bundles (illustrative)', at: Date.now() - 20 * HR_, meta: ['IMU ✓ on 2,204', 'GPS indoor ±12 m'] },
|
||||
{ id: 'O2', i: 'wave', f: 'eyes', t: 'Audio fingerprints align 1,980 clips to ±9 ms', src: 'Derived', at: Date.now() - 19.8 * HR_, meta: ['common track'] },
|
||||
{ id: 'O3', i: 'video', f: 'eyes', t: 'Venue public camera, concourse north', src: 'Public feed (illustrative)', at: Date.now() - 20 * HR_, meta: ['720p'] },
|
||||
{ id: 'O4', i: 'ticket', f: 'ground', t: 'Seat map & stage geometry', src: 'Published venue plan', at: Date.now() - 30 * 24 * HR_, meta: ['static'] },
|
||||
],
|
||||
hyps: [
|
||||
{ id: 'H1', by: 'crowd', text: 'The encore began at 22:41:07 local, ±0.2 s.', conf: 0.9, ci: [0.84, 0.95], cites: ['O1', 'O2'], agree: 210, dispute: 3, status: 'supported' },
|
||||
{ id: 'H2', by: 'geo', text: 'The pyro cue at 22:44 is visible from 86 of 92 seat blocks; 6 blocks are occluded by the roof cantilever.', conf: 0.71, ci: [0.6, 0.8], cites: ['O1', 'O4'], agree: 44, dispute: 6, status: 'supported' },
|
||||
],
|
||||
beats: [
|
||||
{ at: Date.now() - 20 * HR_ - 10 * MIN, t: '22:41', text: 'Lights down. 2,311 phones come up together.', cites: ['H1'], cam: { lat: 51.5552, lon: -0.2815, range: 0.00035, heading: 0.9, tilt: 1.15 } },
|
||||
{ at: Date.now() - 20 * HR_, t: '22:44', text: 'Pyro cue — seen from 86 blocks at once.', cites: ['H2'], cam: { lat: 51.5568, lon: -0.2778, range: 0.0003, heading: 3.6, tilt: 1.12 } },
|
||||
{ at: Date.now() - 20 * HR_ + 12 * MIN, t: '22:56', text: 'Final chorus; the crowd reconstruction is densest here.', cites: ['O1', 'O2'], cam: { lat: 51.556, lon: -0.2796, range: 0.0006, heading: 2.2, tilt: 0.9 } },
|
||||
],
|
||||
suggestions: ['Which seat blocks saw the pyro cue?', 'Align the concourse camera to the crowd timeline'],
|
||||
fallback: 'The concourse camera [O3] shows the crowd surge 2.1 s after the audio onset in [O2]; offsetting its clock by −2.1 s aligns it with 1,980 crowd clips [O1].\nConfidence: 0.58',
|
||||
},
|
||||
climb: {
|
||||
id: 'climb', sit: 'climb', title: 'Free-climber on a Midtown tower', place: 'Midtown Manhattan (fictional tower)', date: 'this morning',
|
||||
window: { start: Date.now() - 11 * HR_, end: Date.now() - 7 * HR_ }, people: ['news', 'sky', 'kora', 'geo', 'ais'],
|
||||
cam: { lat: 40.7533, lon: -73.9866, range: 0.00034, heading: 0.62, tilt: 1.2 },
|
||||
obs: [
|
||||
{ id: 'O1', i: 'helicopter', f: 'sky', t: 'ADS-B tracks: 3 news helicopters orbiting the tower', src: 'ADS-B (illustrative callsigns)', at: Date.now() - 9 * HR_, meta: ['1 Hz', '2 h 14 m'] },
|
||||
{ id: 'O2', i: 'video', f: 'eyes', t: 'Broadcast frames, time-coded', src: 'Public broadcast (illustrative)', at: Date.now() - 9 * HR_, meta: ['29.97 fps'] },
|
||||
{ id: 'O3', i: 'seal', f: 'eyes', t: '41 signed street-level clips', src: 'Signed bundles (illustrative)', at: Date.now() - 9 * HR_, meta: ['IMU ✓', 'GPS ±5 m'] },
|
||||
{ id: 'O4', i: 'cube', f: 'infra', t: 'Tower model, 412 m (fictional building)', src: 'Model', at: Date.now() - 400 * 24 * HR_, meta: ['static'] },
|
||||
],
|
||||
hyps: [
|
||||
{ id: 'H1', by: 'news', text: 'The climb started at 06:52 local on the northeast face.', conf: 0.77, ci: [0.66, 0.86], cites: ['O2', 'O3'], agree: 58, dispute: 4, status: 'supported' },
|
||||
{ id: 'H2', by: 'geo', text: 'Climber height over time is recoverable to ±3 m by triangulating helicopter camera positions (from ADS-B) with frame bearings.', conf: 0.69, ci: [0.55, 0.8], cites: ['O1', 'O2', 'O4'], agree: 33, dispute: 7, status: 'supported' },
|
||||
],
|
||||
beats: [
|
||||
{ at: Date.now() - 10.5 * HR_, t: '06:52', text: 'First witness clip from the street: the climber is at the 3rd setback.', cites: ['H1', 'O3'], cam: { lat: 40.7538, lon: -73.9852, range: 0.00022, heading: 0.5, tilt: 1.3 } },
|
||||
{ at: Date.now() - 9.5 * HR_, t: '07:55', text: 'Three news helicopters in orbit; their ADS-B tracks pin each broadcast camera.', cites: ['O1', 'H2'], cam: { lat: 40.751, lon: -73.99, range: 0.0012, heading: 0.7, tilt: 1.0 } },
|
||||
{ at: Date.now() - 8.6 * HR_, t: '09:06', text: 'The climber reaches the crown at 384 m.', cites: ['H2'], cam: { lat: 40.7549, lon: -73.9848, range: 0.0003, heading: 0.4, tilt: 1.42 } },
|
||||
],
|
||||
suggestions: ['Triangulate the climber at 08:30 from two helicopters', 'Which face was the route on?'],
|
||||
fallback: 'At 08:30 the bearings from N9 NEWS and SKY 4 [O1] to the climber in broadcast frames [O2] intersect 232 ± 4 m up the northeast face of the tower model [O4].\nConfidence: 0.66',
|
||||
},
|
||||
};
|
||||
THR.data = THREADS;
|
||||
|
||||
/* ---------- 3D extras per reconstruction (GPU sets, built lazily) ---------- */
|
||||
const EXTRAS = {};
|
||||
GE.extrasFor = function (id) {
|
||||
if (EXTRAS[id] !== undefined) return EXTRAS[id];
|
||||
const T = THREADS[id] || (GE.SIT.byId[id]?.thread && THREADS[GE.SIT.byId[id].thread]);
|
||||
if (!T) return (EXTRAS[id] = null);
|
||||
const sp = [], ln = [];
|
||||
const gold = [1, 0.84, 0.42, 1];
|
||||
if (T.id === 'koshi') {
|
||||
GE.scenes.ensure(GE.scenes.byId.koshi);
|
||||
const gz = (la, lo) => GE.scenes.groundAt(la, lo) / 1000;
|
||||
for (const o of T.obs) if (o.lat) { const z = gz(o.lat, o.lon); pushSpr(sp, geo.vec(o.lat, o.lon, z + 0.09), gold, 26, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [geo.vec(o.lat, o.lon, z), geo.vec(o.lat, o.lon, z + 0.09)], [1, 0.85, 0.5, 0.9]);
|
||||
// capture frustum toward the river
|
||||
const brg = { O5: 214, O6: 352, O7: 160 }[o.id] * D2R || 0; const tip = geo.vec(o.lat, o.lon, z + 0.012);
|
||||
for (const da of [-0.35, 0.35]) for (const dz of [-0.04, 0.04]) { const [la2, lo2] = geo.dest(o.lat, o.lon, brg + da, 0.45); pushPolyline(ln, [tip, geo.vec(la2, lo2, gz(la2, lo2) + 0.012 + dz)], [1, 0.82, 0.4, 0.55]); }
|
||||
}
|
||||
// gauge & villages
|
||||
pushSpr(sp, geo.vec(28.158, 85.336, gz(28.158, 85.336) + 0.06), [0.4, 0.75, 1, 1], 20, 9, -10, -1e9, 1e9, 0, 0.3, 0.4);
|
||||
// rescue helicopter track (illustrative) up the valley
|
||||
const trk = KO.map(([la, lo], i) => geo.vec(la, lo, gz(la, lo) + 0.35 + (i % 3) * 0.05)); pushPolyline(ln, trk, [0.62, 1, 0.6, 0.7], 1);
|
||||
} else if (T.id === 'concert') {
|
||||
const c = [51.556, -0.2796]; const stage = geo.vec(...geo.offset(c[0], c[1], -40, 0), 0.015);
|
||||
const r = rng(9);
|
||||
for (let i = 0; i < 160; i++) { const a = r() * TAU; const rx = 70 + r() * 55, ry = 52 + r() * 50; const [la, lo] = geo.offset(c[0], c[1], Math.cos(a) * rx, Math.sin(a) * ry); const z = 0.008 + r() * 0.03; const p = geo.vec(la, lo, z); pushSpr(sp, p, [1, 0.86, 0.45, 1], 7, 0, -10, -1e9, 1e9, 0, 0, 0.6); if (i % 2 === 0) pushPolyline(ln, [p, v3.lerp(p, stage, 0.35 + r() * 0.2)], [1, 0.8, 0.4, 0.28]); }
|
||||
pushSpr(sp, stage, [1, 0.5, 1, 1], 30, 9, -10, -1e9, 1e9, 0, 0.1, 0.9);
|
||||
} else if (T.id === 'climb') {
|
||||
const tw = [40.7553, -73.9838];
|
||||
for (const hc of GE.HELI_CIRCUITS.filter((x) => x.orbit && x.lat === 40.7553)) { const pts = []; for (let k = 0; k <= 64; k++) { const [la, lo] = geo.dest(hc.lat, hc.lon, (k / 64) * TAU, hc.r); pts.push(geo.vec(la, lo, hc.alt)); } pushPolyline(ln, pts, [0.62, 1, 0.6, 0.55], 1); }
|
||||
const r = rng(5);
|
||||
for (let i = 0; i < 41; i++) { const a = r() * TAU, d = 0.12 + r() * 0.35; const [la, lo] = geo.dest(tw[0], tw[1], a, d); const p = geo.vec(la, lo, 0.002); pushSpr(sp, p, [1, 0.84, 0.42, 1], 11, 6, -10, -1e9, 1e9, 0, 0, 0.5); pushPolyline(ln, [p, geo.vec(tw[0], tw[1], 0.12 + r() * 0.25)], [1, 0.8, 0.4, 0.22]); }
|
||||
}
|
||||
const set = { sprites: sp.length ? [makeSpriteSet('x' + id, sp)] : null, lines: ln.length ? [makeLineSet(ln)] : null };
|
||||
return (EXTRAS[id] = set);
|
||||
};
|
||||
GE.setExtras = function (id) { GE.world.activeExtras = id ? GE.extrasFor(id) : null; GE.world.activeExtrasId = id; };
|
||||
|
||||
/* ---------- ledger (real SHA-256 hash chain) ---------- */
|
||||
THR.ledgers = {};
|
||||
async function buildLedger(T) {
|
||||
const entries = []; let ts = T.window.start - 6 * HR_;
|
||||
const push = (type, actor, payload) => { entries.push({ i: entries.length, ts: (ts += 7 * MIN + entries.length * 3 * MIN), type, actor, payload }); };
|
||||
push('thread.create', 'kora_maps', { title: T.title, place: T.place });
|
||||
for (const o of T.obs) push('observation.add', o.src.startsWith('@') ? o.src.split(' ')[0].slice(1) : 'ingest', { id: o.id, title: o.t, source: o.src, at: o.at, contentHash: o.hash });
|
||||
for (const hy of T.hyps) { push(hy.ai ? 'ai.proposal' : 'hypothesis.propose', hy.ai ? 'claude' : P[hy.by].h, hy.ai ? { id: hy.id, text: hy.text, cites: hy.cites, prompt: hy.prompt, model: 'claude (window.claude sample)', diff: '+' + hy.id } : { id: hy.id, text: hy.text, cites: hy.cites, confidence: hy.conf }); push('vote', P[T.people[(entries.length) % T.people.length]].h, { target: hy.id, agree: true }); }
|
||||
let prev = '0'.repeat(64);
|
||||
for (const e of entries) { e.digest = await sha256(canon(e.payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); prev = e.hash; }
|
||||
THR.ledgers[T.id] = entries; return entries;
|
||||
}
|
||||
THR.append = async function (T, type, actor, payload) {
|
||||
const L = THR.ledgers[T.id] || (await buildLedger(T)); const prev = L[L.length - 1].hash;
|
||||
const e = { i: L.length, ts: Date.now(), type, actor, payload }; e.digest = await sha256(canon(payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); L.push(e); return e;
|
||||
};
|
||||
THR.verify = async function (L, onStep) {
|
||||
let prev = '0'.repeat(64), ok = true;
|
||||
for (const e of L) { const dg = await sha256(canon(e.payload)); const hh = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: dg, prev })); const good = ok && dg === e.digest && e.prev === prev && hh === e.hash; if (!good) ok = false; onStep && (await onStep(e, good)); prev = e.hash; }
|
||||
return ok;
|
||||
};
|
||||
THR.init = async function () {
|
||||
for (const T of Object.values(THREADS)) { for (const o of T.obs) o.hash = await sha256(canon({ id: o.id, t: o.t, src: o.src, at: o.at, meta: o.meta })); await buildLedger(T); }
|
||||
};
|
||||
|
||||
/* ---------- list ---------- */
|
||||
THR.render = function () {
|
||||
const v = $('#v-threads');
|
||||
v.innerHTML = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Threads</div><div class="lt"><div class="kicker">${ic('seal', 'xs')} Signed evidence · open ledgers</div><div class="row"><h1>Threads</h1><button class="avatar md" style="--h:205" id="thMe">Y</button></div></div>
|
||||
<div class="livebar" style="padding-bottom:14px"><span class="presence"><span class="stack">${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `<span class="avatar sm on" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span><span><b class="num">37</b> people reconstructing now</span></div>
|
||||
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `<article class="tcard" data-t="${T.id}" data-id="${T.sit}"><div class="thumb"><canvas width="640" height="360"></canvas><div class="bl"><div><h3>${esc(T.title)}</h3><p>${esc(T.place)} · ${esc(T.date)}</p></div><span class="badge d4">4D</span></div></div><div class="stats"><div><b>${T.obs.length}</b>observations</div><div><b>${T.hyps.length}</b>hypotheses</div><div><b>${THR.ledgers[T.id]?.length || '–'}</b>ledger</div><span class="stack">${T.people.slice(0, 4).map((k) => `<span class="avatar sm" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></div></article>`; }).join('')}
|
||||
<div style="padding:6px 14px 150px"><button class="btn" style="width:100%" id="newThread">${ic('plus', 'sm')}Start a reconstruction</button><p style="font-size:11.5px;color:var(--text3);text-align:center;margin-top:12px">Concept · illustrative data. Witness posts, handles and claims are fictional.</p></div></div>
|
||||
<div class="ws" id="ws"></div>`;
|
||||
$('#thMe').addEventListener('click', () => APP.setTab('you'));
|
||||
$('#newThread').addEventListener('click', () => APP.toast(`${ic('threads', 'sm')} New thread · add your first observation`));
|
||||
$$('.tcard').forEach((c) => {
|
||||
c.addEventListener('click', () => THR.openWs(c.dataset.t));
|
||||
const cv = c.querySelector('canvas'); const s = GE.SIT.byId[c.dataset.id]; const done = GE.thumbs.done.get(s.id);
|
||||
if (done) cv.getContext('2d').drawImage(done, 0, 0, cv.width, cv.height); else { GE.thumbs.fallback(s, cv); }
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- workspace ---------- */
|
||||
THR.openWs = function (id) {
|
||||
const T = THREADS[id]; if (!T) return; const W = GE.world;
|
||||
if (APP.tab !== 'threads') APP.setTab('threads');
|
||||
THR.ws = { T, seg: THR.ws?.T === T ? THR.ws.seg : 'obs', beats: T.beats, playing: false };
|
||||
$('#tlist').style.display = 'none';
|
||||
const ws = $('#ws'); ws.classList.add('on');
|
||||
W.setWindow(T.window); W.setTime(T.beats[Math.min(2, T.beats.length - 1)].at);
|
||||
GE.setExtras(T.id);
|
||||
for (const lid of ['signed', 'holo', 'helis']) W.layerById[lid].on = true;
|
||||
const wide = APP.isWide();
|
||||
Cam.autoSpin = 0; Cam.flyTo({ ...T.cam, shiftY: wide ? 0 : 0.6, shiftX: 0 });
|
||||
APP.setMode('workspace');
|
||||
THR.renderWs();
|
||||
};
|
||||
THR.closeWs = function (silent) {
|
||||
if (!THR.ws) return; THR.stop(); THR.ws = null; const W = GE.world;
|
||||
$('#ws').classList.remove('on'); $('#ws').innerHTML = ''; const tl = $('#tlist'); if (tl) tl.style.display = '';
|
||||
W.setWindow(null); W.setTime(null); GE.setExtras(null); APP.setMode('');
|
||||
if (!silent) Cam.flyTo(APP.heroCam());
|
||||
};
|
||||
THR.renderWs = function () {
|
||||
const { T, seg } = THR.ws; const ws = $('#ws');
|
||||
ws.innerHTML = `<div class="wsh"><button class="circ glass" id="wsBack" aria-label="Back">${ic('chevl')}</button><div class="tt glass"><span style="min-width:0;flex:1"><b>${esc(T.title)}</b><span>${esc(T.place)} · ${esc(T.date)}</span></span><span class="presence"><span class="stack">${T.people.slice(0, 4).map((k, i) => `<span class="avatar sm ${i < 3 ? 'on' : ''}" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span></div></div>
|
||||
<div class="body glass strong"><div class="seg" id="wsSeg">${[['obs', 'Observations'], ['hyp', 'Hypotheses'], ['nar', 'Narrative'], ['led', 'Ledger']].map(([k, l]) => `<button data-s="${k}" class="${seg === k ? 'on' : ''}">${l}</button>`).join('')}</div><div class="wsc" id="wsc"></div>
|
||||
${seg === 'hyp' ? `<div class="composer glass strong" id="composer"><div class="sug">${T.suggestions.map((s) => `<button>${esc(s)}</button>`).join('')}</div><div class="row"><textarea id="aiIn" rows="1" placeholder="Ask AI to refine this reconstruction…"></textarea><button class="send" id="aiSend" aria-label="Send">${ic('arrowup')}</button></div></div>` : ''}</div>`;
|
||||
$('#wsBack').addEventListener('click', () => THR.closeWs());
|
||||
$$('#wsSeg button').forEach((b) => b.addEventListener('click', () => { THR.ws.seg = b.dataset.s; THR.renderWs(); haptic(4); }));
|
||||
const c = $('#wsc');
|
||||
if (seg === 'obs') c.innerHTML = `<div class="stratum">${ic('lock', 'xs')} Stratum 1 · Observations — immutable, hashed, sourced</div>` + T.obs.map((o) => `<div class="obs" data-o="${o.id}" style="--c:${GE.FAM[o.f].color}"><span class="oi">${ic(o.i)}</span><div><div class="ot"><span class="tagid">${o.id}</span> ${esc(o.t)}</div><div class="om"><span>${esc(o.src)}</span><span class="num">${fmt.utc(o.at)}</span>${o.meta.map((m) => `<span>${esc(m)}</span>`).join('')}<span class="mono">sha256 ${o.hash ? o.hash.slice(0, 12) + '…' : '…'}</span></div></div></div>`).join('');
|
||||
if (seg === 'hyp') { c.innerHTML = `<div class="stratum">${ic('people', 'xs')} Stratum 2 · Hypotheses — cite, agree, dispute, supersede</div><div id="hypList"></div>`; THR.renderHyps(); THR.bindComposer(); }
|
||||
if (seg === 'nar') THR.renderNarr(c);
|
||||
if (seg === 'led') THR.renderLedger(c);
|
||||
c.querySelectorAll('[data-o]').forEach((el) => el.addEventListener('click', () => { const o = T.obs.find((x) => x.id === el.dataset.o); if (o.lat) Cam.flyTo({ lat: o.lat - 0.004, lon: o.lon - 0.002, range: 0.0012, tilt: 1.15, heading: 0.4, shiftY: APP.isWide() ? 0 : 0.6 }); GE.world.setTime(o.at); APP.toast(`${ic('hash', 'sm')} ${o.id} · sha256 ${o.hash.slice(0, 16)}…`); }));
|
||||
};
|
||||
const confSVG = (h) => { const w = 200; return `<svg viewBox="0 0 ${w} 14" preserveAspectRatio="none"><rect x="0" y="5" width="${w}" height="4" rx="2" fill="rgba(255,255,255,.08)"/><rect x="${h.ci[0] * w}" y="3" width="${(h.ci[1] - h.ci[0]) * w}" height="8" rx="4" fill="${h.ai ? 'rgba(183,156,255,.3)' : 'rgba(124,196,255,.25)'}"/><rect x="${h.conf * w - 1.5}" y="1" width="3" height="12" rx="1.5" fill="${h.ai ? '#B79CFF' : '#7CC4FF'}"/></svg>`; };
|
||||
THR.hypHTML = function (hy) {
|
||||
const p = P[hy.by] || P.ai;
|
||||
return `<div class="hyp ${hy.ai ? 'ai' : ''}" data-h="${hy.id}"><div class="hh">${hy.ai ? `<span class="avatar sm ai">${ic('sparkles', 'xs')}</span>` : `<span class="avatar sm" style="--h:${p.hue}">${p.h[0].toUpperCase()}</span>`}<b>${hy.ai ? 'AI proposal' : '@' + p.h}</b><span class="tagid">${hy.id}</span><span class="stt ${hy.status}">${hy.status}</span></div>
|
||||
<div class="hb" data-text>${esc(hy.text)}</div>
|
||||
<div class="conf"><span>confidence</span>${confSVG(hy)}<b class="num" style="color:var(--text)">${Math.round(hy.conf * 100)}%</b></div>
|
||||
<div class="cites">${hy.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}${hy.prompt ? `<span style="font-size:11.5px;color:var(--text3)">prompt: “${esc(hy.prompt)}” · ${esc(hy.model || 'claude')}</span>` : ''}</div>
|
||||
<div class="hf"><button class="vote ${hy.myVote === 1 ? 'on' : ''}" data-v="1">${ic('thumbup', 'sm')}<span class="num">${hy.agree + (hy.myVote === 1 ? 1 : 0)}</span></button><button class="vote dis ${hy.myVote === -1 ? 'on' : ''}" data-v="-1">${ic('thumbdown', 'sm')}<span class="num">${hy.dispute + (hy.myVote === -1 ? 1 : 0)}</span></button><button class="vote" data-v="0" style="margin-left:auto">${ic('bubble', 'sm')}Cite</button></div></div>`;
|
||||
};
|
||||
THR.renderHyps = function () {
|
||||
const { T } = THR.ws; const host = $('#hypList'); if (!host) return;
|
||||
host.innerHTML = T.hyps.map(THR.hypHTML).join('');
|
||||
host.querySelectorAll('[data-h]').forEach((el) => el.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const hy = T.hyps.find((x) => x.id === el.dataset.h); const v = +b.dataset.v; haptic(5);
|
||||
if (v === 0) { APP.toast(`${ic('bubble', 'sm')} Cite ${hy.id} in a new hypothesis`); return; }
|
||||
hy.myVote = hy.myVote === v ? 0 : v; await THR.append(T, 'vote', 'you', { target: hy.id, agree: v === 1, retract: hy.myVote === 0 });
|
||||
THR.renderHyps(); APP.toast(`${ic('link', 'sm')} Vote recorded in the ledger`);
|
||||
})));
|
||||
};
|
||||
THR.bindComposer = function () {
|
||||
const ta = $('#aiIn'); const send = $('#aiSend'); if (!ta) return;
|
||||
$$('#composer .sug button').forEach((b) => b.addEventListener('click', () => { ta.value = b.textContent; ta.focus(); }));
|
||||
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send.click(); } });
|
||||
send.addEventListener('click', () => { const q = ta.value.trim() || THR.ws.T.suggestions[0]; ta.value = ''; THR.refine(q); });
|
||||
};
|
||||
THR.refine = async function (q) {
|
||||
const { T } = THR.ws; const send = $('#aiSend'); if (send) send.disabled = true;
|
||||
const id = 'H' + (T.hyps.length + 1);
|
||||
const hy = { id, by: 'ai', ai: true, text: '', conf: 0.5, ci: [0.35, 0.65], cites: [], agree: 0, dispute: 0, status: 'proposed', prompt: q, model: '…' };
|
||||
T.hyps.push(hy); THR.renderHyps();
|
||||
const card = $(`[data-h="${id}"]`); card?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
const textEl = () => $(`[data-h="${id}"] [data-text]`);
|
||||
const promptStr = `You are helping a group of people reconstruct a real-world event from evidence, in an app that keeps observations (immutable) separate from hypotheses (claims).\nEvent: ${T.title} — ${T.place} (${T.date}). Some witness posts are illustrative.\nObservations:\n${T.obs.map((o) => `[${o.id}] ${o.t} — ${o.src}, ${new Date(o.at).toISOString().slice(0, 16)}Z (${o.meta.join('; ')})`).join('\n')}\nCurrent hypotheses:\n${T.hyps.filter((x) => x.id !== id).map((x) => `[${x.id}] ${x.text} (confidence ${x.conf})`).join('\n')}\nRequest from a participant: "${q}"\nReply with ONE new proposed hypothesis in at most 70 words. Cite observation IDs in square brackets like [O3]. Do not invent new observations. End with a final line exactly like "Confidence: 0.xx".`;
|
||||
let out = '', model = 'scripted fallback (no AI available here)';
|
||||
const render = (txt) => { out = txt; const el = textEl(); if (el) el.textContent = txt.replace(/\n?Confidence:.*$/s, '').trim() || '…'; };
|
||||
let sample = null;
|
||||
try { sample = window.claude?.use ? await window.claude.use('sample') : null; } catch { sample = null; }
|
||||
if (sample) {
|
||||
try { const r = await sample(promptStr, { onText: ({ text }) => render(text) }); render(r?.text ?? out); model = 'claude · window.claude sample'; }
|
||||
catch (e) { sample = null; }
|
||||
}
|
||||
if (!sample) { const full = T.fallback; for (let i = 1; i <= full.length; i += 3) { render(full.slice(0, i)); await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 12)); } render(full); }
|
||||
const cm = /Confidence:\s*([0-9.]+)/i.exec(out); hy.conf = cm ? clamp(parseFloat(cm[1]), 0.05, 0.95) : 0.55; hy.ci = [Math.max(0, hy.conf - 0.16), Math.min(1, hy.conf + 0.14)];
|
||||
hy.cites = [...new Set((out.match(/\[(O\d+)\]/g) || []).map((x) => x.slice(1, -1)))]; hy.text = out.replace(/\n?Confidence:.*$/s, '').trim(); hy.model = model;
|
||||
const e = await THR.append(T, 'ai.proposal', 'claude', { id, prompt: q, model, inputs: hy.cites, output: hy.text, diff: '+' + id });
|
||||
THR.renderHyps(); if (send) send.disabled = false;
|
||||
APP.toast(`${ic('sparkles', 'sm')} Proposed ${id} · ledger #${e.i} · ${e.hash.slice(0, 10)}…`, 3000);
|
||||
};
|
||||
|
||||
/* narrative */
|
||||
THR.renderNarr = function (c) {
|
||||
const { T } = THR.ws;
|
||||
c.innerHTML = `<div class="stratum">${ic('play', 'xs')} Stratum 3 · Narrative — a camera path through space-time</div><div class="playbar"><button class="playbtn" id="play" aria-label="Play">${ic(THR.ws.playing ? 'pause' : 'play')}</button><div style="flex:1"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-bottom:6px"><span class="num" id="narT">${fmt.utc(GE.world.simMs)}</span><span>${T.beats.length} beats</span></div><div class="prog"><i id="narP"></i></div></div></div><div class="narr">${T.beats.map((b, i) => `<div class="beat" data-b="${i}"><span class="bt">${b.t}<br><span style="font-size:10px;color:var(--text3)">UTC</span></span><div><p>${esc(b.text)}</p><div class="cites">${b.cites.map((x) => `<span class="tagid">${x}</span>`).join('')}</div></div></div>`).join('')}</div>`;
|
||||
$('#play').addEventListener('click', () => (THR.ws.playing ? THR.stop() : THR.play()));
|
||||
c.querySelectorAll('[data-b]').forEach((el) => el.addEventListener('click', () => THR.goBeat(+el.dataset.b)));
|
||||
};
|
||||
THR.goBeat = function (i, dur) { const { T } = THR.ws; const b = T.beats[i]; THR.ws.beat = i; Cam.flyTo({ ...b.cam, shiftY: APP.isWide() ? 0 : 0.6 }, { duration: dur ?? 2.2 }); GE.world.setTime(b.at); $$('.beat').forEach((x) => x.classList.toggle('on', +x.dataset.b === i)); };
|
||||
THR.play = function () {
|
||||
const ws = THR.ws; ws.playing = true; const T = ws.T; const per = 3.6; const t0 = performance.now(); let last = -1;
|
||||
const btn = $('#play'); if (btn) btn.innerHTML = ic('pause');
|
||||
const step = () => {
|
||||
if (!THR.ws || !THR.ws.playing) return; const el = (performance.now() - t0) / 1000; const total = per * T.beats.length;
|
||||
const f = clamp(el / total, 0, 1); const bi = Math.min(T.beats.length - 1, Math.floor(el / per));
|
||||
if (bi !== last) { last = bi; THR.goBeat(bi, per * 0.9); }
|
||||
const a = T.beats[bi], b = T.beats[Math.min(T.beats.length - 1, bi + 1)]; const u = clamp((el - bi * per) / per, 0, 1);
|
||||
GE.world.setTime(lerp(a.at, b.at, bi === T.beats.length - 1 ? 0 : u));
|
||||
const p = $('#narP'); if (p) p.style.width = (f * 100).toFixed(1) + '%'; const nt = $('#narT'); if (nt) nt.textContent = fmt.utc(GE.world.simMs);
|
||||
if (f >= 1) { THR.stop(); return; } requestAnimationFrame(step);
|
||||
};
|
||||
step();
|
||||
};
|
||||
THR.stop = function () { if (!THR.ws) return; THR.ws.playing = false; const btn = $('#play'); if (btn) btn.innerHTML = ic('play'); };
|
||||
|
||||
/* ledger */
|
||||
THR.renderLedger = function (c) {
|
||||
const { T } = THR.ws; const L = THR.ledgers[T.id] || [];
|
||||
c.innerHTML = `<div class="stratum">${ic('link', 'xs')} Ledger — SHA-256 hash chain · ${L.length} entries</div><div class="btnrow" style="margin:4px 0 10px"><button class="btn sm pri" id="lv">${ic('check', 'sm')}Verify chain</button><button class="btn sm" id="lt">${THR._tampered ? 'Restore entry #3' : 'Tamper with #3'}</button><button class="btn sm" id="lx">${ic('share', 'sm')}Export</button></div><div class="ledger" id="led">${L.map((e) => `<div class="le" data-i="${e.i}"><div class="lh"><span class="ix">#${e.i}</span><span class="ty">${esc(e.type)} <span>· @${esc(e.actor)}</span></span><span class="vk" data-vk></span></div><div style="margin-left:34px" class="hs">${e.hash.slice(0, 20)}… ← ${e.prev.slice(0, 8)}…</div><pre>${esc(JSON.stringify({ i: e.i, ts: new Date(e.ts).toISOString(), type: e.type, actor: e.actor, payload: e.payload, digest: e.digest, prev: e.prev, hash: e.hash }, null, 1))}</pre></div>`).join('<div class="chain"></div>')}</div>`;
|
||||
c.querySelectorAll('.le .lh').forEach((x) => x.addEventListener('click', () => x.parentElement.classList.toggle('open')));
|
||||
$('#lv').addEventListener('click', async () => {
|
||||
c.querySelectorAll('[data-vk]').forEach((x) => { x.className = 'vk'; x.innerHTML = ''; });
|
||||
const ok = await THR.verify(L, async (e, good) => { const el = c.querySelector(`.le[data-i="${e.i}"] [data-vk]`); if (el) { el.className = 'vk ' + (good ? 'ok' : 'bad'); el.innerHTML = ic(good ? 'check' : 'x', 'xs'); el.scrollIntoView({ block: 'nearest' }); } await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 45)); });
|
||||
APP.toast(ok ? `${ic('check', 'sm')} Chain verified · ${L.length} links intact` : `${ic('x', 'sm')} Chain broken at #3 — a payload no longer matches its digest`, 2800); haptic(ok ? 10 : [20, 60, 20]);
|
||||
});
|
||||
$('#lt').addEventListener('click', () => { const e = L[3]; if (!e) return; if (THR._tampered) { e.payload = THR._tampered; THR._tampered = null; } else { THR._tampered = e.payload; e.payload = { ...e.payload, title: (e.payload.title || e.payload.text || '') + ' (edited)' }; } THR.renderLedger(c); APP.toast(THR._tampered ? 'Entry #3 edited locally — now verify' : 'Entry #3 restored'); });
|
||||
$('#lx').addEventListener('click', () => { try { const blob = new Blob([JSON.stringify(L, null, 1)], { type: 'application/json' }); const a = h('a', { href: URL.createObjectURL(blob), download: `ledger-${T.id}.json` }); document.body.append(a); a.click(); a.remove(); } catch {} APP.toast(`${ic('share', 'sm')} Ledger exported as JSON`); });
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/* You: captures and their seals (+ verify inspector), holograms left in places, followed situations,
|
||||
alert radius, rendering quality, the concept marker and credits. */
|
||||
const YOU = (GE.you = { radius: store.get('radius', 250), follows: store.get('follows', { cyclone: true, koshi: true, aurora: true, iss: false, launch: true }) });
|
||||
YOU.render = function () {
|
||||
const v = $('#v-you'); const st = v.scrollTop; const W = GE.world;
|
||||
const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')];
|
||||
v.innerHTML = `<div style="background:var(--bg);min-height:100%;padding-bottom:150px"><div class="navbar">You</div>
|
||||
<div class="lt"><div class="kicker">${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}</div><h1>You</h1></div>
|
||||
<div class="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your window</h2><div style="color:var(--text2);font-size:13px;margin:2px 0 8px">Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}</div><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="stats4"><div><b>${caps.length}</b>captures</div><div><b>${holos.length}</b>holograms</div><div><b>${Object.values(YOU.follows).filter(Boolean).length}</b>following</div><div><b>${caps.length ? '100%' : '—'}</b>verified</div></div>
|
||||
<div class="sec-h"><h2>Captures</h2><span>sealed on this device</span></div>
|
||||
<div class="capgrid">${caps.length ? caps.map((c, i) => `<div class="capc" data-c="${i}"><div class="im" style="background-image:url(${c.thumb})"><svg class="seal"><use href="#i-seal"/></svg></div><div class="cm"><b>${esc(c.place)}</b><span class="num">${fmt.utc(c.at)}</span><div class="mono">${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}</div></div></div>`).join('') : `<div style="grid-column:1/-1;color:var(--text3);font-size:14px;padding:6px 4px">No captures yet — open Capture › Witness and press the shutter.</div>`}</div>
|
||||
<div class="sec-h"><h2>Holograms</h2><span>left in places</span></div>
|
||||
<div class="grp">${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `<div class="row2"><span class="ri" style="--c:var(--gold)">${ic('cube')}</span><span class="rt">${esc(x.title)}<small>${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}</small></span>${x.empty ? '' : `<button class="rv" data-fly="${x.lat},${x.lon}">${ic('chevr', 'sm')}</button>`}</div>`).join('')}</div>
|
||||
<div class="sec-h"><h2>Following</h2><span>alerts</span></div>
|
||||
<div class="grp">${Object.entries(YOU.follows).map(([id, on]) => { const s = GE.SIT.byId[id]; if (!s) return ''; return `<div class="row2"><span class="ri" style="--c:${GE.FAM[s.fam].color}">${ic(GE.FAM[s.fam].icon)}</span><span class="rt">${esc(s.title.split(' — ')[0])}<small>${esc(s.place)}</small></span><button class="sw2 ${on ? 'on' : ''}" data-f="${id}" role="switch" aria-checked="${on}" aria-label="Follow"></button></div>`; }).join('')}</div>
|
||||
<div class="sec-h"><h2>Alert radius</h2><span class="num" id="radV">${YOU.radius} km</span></div>
|
||||
<div class="grp radius"><svg viewBox="0 0 300 120" id="radSvg"></svg><input type="range" min="10" max="2000" step="10" value="${YOU.radius}" id="rad" aria-label="Alert radius"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text3)"><span>10 km</span><span id="radN" class="num"></span><span>2,000 km</span></div></div>
|
||||
<div class="sec-h"><h2>Rendering</h2><span>adaptive</span></div>
|
||||
<div class="grp"><div class="row2"><span class="ri" style="--c:var(--sky)">${ic('gauge')}</span><span class="rt">Resolution scale<small>Drops when frames take > 20 ms for 2 s</small></span><span class="rv num" id="qScale"></span></div>
|
||||
<div class="row2"><span class="ri" style="--c:var(--sky)">${ic('cpu')}</span><span class="rt">Frame time<small>Post chain: atmosphere → FXAA → half-res bloom → grade</small></span><span class="rv num" id="qFps"></span></div>
|
||||
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('sparkles')}</span><span class="rt">Bloom</span><button class="sw2 ${W.quality.bloom ? 'on' : ''}" data-q="bloom" role="switch" aria-label="Bloom"></button></div>
|
||||
<div class="row2"><span class="ri" style="--c:var(--orbit)">${ic('frame')}</span><span class="rt">Film grain</span><button class="sw2 ${W.quality.grain ? 'on' : ''}" data-q="grain" role="switch" aria-label="Grain"></button></div>
|
||||
<div class="row2"><span class="ri" style="--c:var(--text3)">${ic('gyro')}</span><span class="rt">Reduced motion<small>Follows your system setting</small></span><span class="rv">${prefersReduced() ? 'On' : 'Off'}</span></div></div>
|
||||
<div class="sec-h"><h2>About</h2><span>vision mockup</span></div>
|
||||
<div class="grp credits"><p style="margin:0 0 10px"><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></p>
|
||||
<p style="margin:0 0 8px"><b>God’s Eye</b> — the window through which you see the world, and the eye through which the world sees itself. This is a design vision: every flight, ship, satellite, quake, fire, cyclone, camera, person, handle, post and reconstruction claim here is synthetic or fictional, placed in real geography.</p>
|
||||
<p style="margin:0 0 8px"><b>Imagery:</b> NASA Earth Observatory / Visible Earth — Blue Marble: Next Generation (cloudless day), Black Marble 2016 (night lights), Blue Marble cloud mosaic, land/water mask and topography. Public domain, courtesy of NASA.</p>
|
||||
<p style="margin:0 0 8px"><b>Real crypto:</b> SHA-256 (WebCrypto) over every rendered capture and ledger entry; ECDSA P-256 signatures with a non-extractable key generated on this device.</p>
|
||||
<p style="margin:0"><b>Renderer:</b> hand-written WebGL2 — ray-traced planet with single-scatter atmosphere, day/night terminator from the real sun position, ocean glint, clouds, stars, procedural cities and terrain, GPU-propagated flights and orbits.</p></div></div>`;
|
||||
v.scrollTop = st;
|
||||
v.querySelectorAll('[data-c]').forEach((el) => el.addEventListener('click', () => YOU.inspect(+el.dataset.c)));
|
||||
v.querySelectorAll('[data-f]').forEach((el) => el.addEventListener('click', () => { const id = el.dataset.f; YOU.follows[id] = !YOU.follows[id]; el.classList.toggle('on', YOU.follows[id]); el.setAttribute('aria-checked', YOU.follows[id]); store.set('follows', YOU.follows); haptic(5); }));
|
||||
v.querySelectorAll('[data-q]').forEach((el) => el.addEventListener('click', () => { const k = el.dataset.q; W.quality[k] = !W.quality[k]; el.classList.toggle('on', W.quality[k]); haptic(5); }));
|
||||
v.querySelectorAll('[data-fly]').forEach((el) => el.addEventListener('click', () => { const [la, lo] = el.dataset.fly.split(',').map(Number); APP.setTab('globe'); Cam.flyTo({ lat: la, lon: lo, range: 0.002, tilt: 1.1 }); }));
|
||||
const rad = $('#rad'); rad.addEventListener('input', () => { YOU.radius = +rad.value; $('#radV').textContent = YOU.radius + ' km'; store.set('radius', YOU.radius); YOU.drawRadius(); });
|
||||
YOU.drawRadius();
|
||||
};
|
||||
YOU.drawRadius = function () {
|
||||
const svg = $('#radSvg'); if (!svg) return; const W = GE.world; const me = GE.ME; const r = YOU.radius;
|
||||
const spanKm = Math.max(600, r * 2.6); const sx = 300 / spanKm, sy = 120 / (spanKm * 0.4);
|
||||
const P2 = (la, lo) => [150 + (lo - me.lon) * 111 * Math.cos(me.lat * D2R) * sx, 60 - (la - me.lat) * 111 * sy];
|
||||
let s = `<rect width="300" height="120" fill="#070A10"/>`;
|
||||
for (const c of GE.CITY_V) { const [x, y] = P2(c.lat, c.lon); if (x > -5 && x < 305 && y > -5 && y < 125) s += `<circle cx="${x}" cy="${y}" r="1.6" fill="rgba(255,210,140,.5)"/>`; }
|
||||
let n = 0; for (const s0 of GE.SIT.list) { const d = geo.dist(me.lat, me.lon, s0.lat, s0.lon) * EARTH_KM; const [x, y] = P2(s0.lat, s0.lon); if (d < r) n++; if (x > 0 && x < 300 && y > 0 && y < 120) s += `<circle cx="${x}" cy="${y}" r="3" fill="${GE.FAM[s0.fam].color}"/>`; }
|
||||
for (const q of GE.QUAKES) { const d = geo.dist(me.lat, me.lon, q.lat, q.lon) * EARTH_KM; if (d < r) n++; }
|
||||
s += `<ellipse cx="150" cy="60" rx="${r * sx}" ry="${r * sy}" fill="rgba(124,196,255,.08)" stroke="#7CC4FF" stroke-width="1.2" stroke-dasharray="4 3"/><circle cx="150" cy="60" r="3.5" fill="#fff"/><text x="156" y="57" font-size="10" fill="rgba(255,255,255,.75)" font-family="var(--font)">${esc(me.name)}</text>`;
|
||||
svg.innerHTML = s; const nn = $('#radN'); if (nn) nn.textContent = `${n} alerts in range this week`;
|
||||
};
|
||||
YOU.tick = function () { const a = $('#qScale'); if (a) a.textContent = `${GE.gov.scale.toFixed(2)}× · ${GE.gov.w}×${GE.gov.h}`; const b = $('#qFps'); if (b) b.textContent = `${GE.gov.ms.toFixed(1)} ms`; };
|
||||
YOU.inspect = async function (i) {
|
||||
const c = GE.captures[i]; if (!c) return; const el = $('#inspector');
|
||||
el.innerHTML = `<div class="panel glass strong"><div class="grab" style="height:22px;display:grid;place-items:center"><span style="width:38px;height:5px;border-radius:3px;background:rgba(255,255,255,.3)"></span></div>
|
||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px"><div style="width:92px;height:69px;border-radius:12px;background:url(${c.thumb}) center/cover;flex:none;position:relative"><svg class="seal" style="position:absolute;right:-6px;top:-6px"><use href="#i-seal"/></svg></div><div><div style="font-weight:700;font-size:17px">Verify capture</div><div style="color:var(--text2);font-size:13px">${esc(c.place)} · <span class="num">${fmt.utc(c.at)}</span></div></div><button class="circ" id="insX" style="margin-left:auto;background:rgba(255,255,255,.08)">${ic('x')}</button></div>
|
||||
<div class="steps" id="vsteps">${['Media hash recomputed (SHA-256)', 'Bundle hash recomputed', 'Signature valid — ECDSA P-256, device public key', 'IMU agrees with the image horizon (±0.8°)', 'GPS track agrees with capture time', 'Chain link to previous bundle'].map((t, k) => `<div class="step" data-k="${k}"><span class="ck">${ic('check', 'xs')}</span><div><b>${t}</b><div class="h" data-h></div></div></div>`).join('')}</div>
|
||||
<div style="margin-top:10px;font-size:11px;color:var(--text3);font-family:var(--mono);word-break:break-all">pubkey x ${c.jwk?.x || '—'}<br>pubkey y ${c.jwk?.y || '—'}</div>
|
||||
<p style="font-size:11.5px;color:var(--text3);margin:10px 0 0">In a native build the key lives in the Secure Enclave / StrongBox and the bundle is written as a C2PA manifest.</p></div>`;
|
||||
el.classList.add('on');
|
||||
$('#insX').addEventListener('click', () => el.classList.remove('on'));
|
||||
el.addEventListener('click', (e) => { if (e.target === el) el.classList.remove('on'); });
|
||||
const mark = async (k, ok, txt) => { await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 260)); const s = $(`#vsteps [data-k="${k}"]`); if (!s) return; s.classList.add('on'); if (ok) s.classList.add('done'); else s.querySelector('.ck').style.cssText = 'background:var(--live);box-shadow:none;color:#fff'; s.querySelector('[data-h]').textContent = txt; haptic(3); };
|
||||
const mh = await sha256(c.bytes); await mark(0, mh === c.mediaHash, mh);
|
||||
const bh = await sha256(canon({ media: mh, session: c.session, context: c.context, prev: c.prev })); await mark(1, bh === c.hash, bh);
|
||||
const ok = await GE.crypto.verifyHex(c.hash, c.sig); await mark(2, ok, ok ? (c.sig || '').slice(0, 64) + '…' : 'no signature available in this browser');
|
||||
await mark(3, true, `roll ${c.session.imu[0].g}° · pitch ${c.session.imu[0].b}° vs rendered horizon`);
|
||||
await mark(4, true, `${c.session.gps.length} fixes within ${(c.session.end - c.session.start) / 1000} s of the shutter`);
|
||||
const prev = i > 0 ? GE.captures[i - 1].hash : '0'.repeat(64); await mark(5, prev === c.prev, `prev ${c.prev.slice(0, 32)}…`);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Boot, render loop, adaptive-DPR governor, cold open. */
|
||||
const HASH = (() => { const o = {}; try { (location.hash || '').slice(1).split('&').forEach((kv) => { const [k, v] = kv.split('='); if (k) o[decodeURIComponent(k)] = v == null ? true : decodeURIComponent(v); }); } catch {} return o; })();
|
||||
GE.HASH = HASH;
|
||||
const gov = (GE.gov = { levels: [1, 0.85, 0.72, 0.6, 0.5], li: 0, scale: 1, ms: 16, w: 0, h: 0, acc: 0, n: 0, slow: 0, fast: 0, pinned: HASH.dpr ? parseFloat(HASH.dpr) : null });
|
||||
let chain = null; const canvas = $('#gl');
|
||||
function sizeCanvas() {
|
||||
const cw = canvas.clientWidth || innerWidth, chh = canvas.clientHeight || innerHeight;
|
||||
const dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
const s = gov.pinned || dpr * gov.levels[gov.li]; gov.scale = s;
|
||||
const w = Math.max(2, Math.round(cw * s)), hh = Math.max(2, Math.round(chh * s));
|
||||
if (canvas.width !== w || canvas.height !== hh) { canvas.width = w; canvas.height = hh; }
|
||||
if (!chain || chain.w !== w || chain.h !== hh) { chain && chain.dispose(); chain = GL.chain(w, hh); }
|
||||
gov.w = w; gov.h = hh; return [cw, chh];
|
||||
}
|
||||
function governor(dt) {
|
||||
if (gov.pinned) return;
|
||||
gov.ms = lerp(gov.ms, dt * 1000, 0.08);
|
||||
if (gov.ms > 21) { gov.slow += dt; gov.fast = 0; } else if (gov.ms < 13.5) { gov.fast += dt; gov.slow = 0; } else { gov.slow = Math.max(0, gov.slow - dt); gov.fast = 0; }
|
||||
if (gov.slow > 2 && gov.li < gov.levels.length - 1) { gov.li++; gov.slow = 0; bus.emit('quality', { scale: gov.scale, fps: 1000 / gov.ms }); }
|
||||
if (gov.fast > 5 && gov.li > 0) { gov.li--; gov.fast = 0; }
|
||||
W.quality.steps = gov.li >= 3 ? 8 : 12;
|
||||
}
|
||||
function globeVisible() {
|
||||
if (APP.intro) return true;
|
||||
if (APP.isWide()) return true;
|
||||
if (APP.tab === 'globe' || APP.tab === 'capture' || APP.mode) return true;
|
||||
if (APP.tab === 'now') { const v = $('#v-now'); return v.scrollTop < v.querySelector('.now-hero')?.offsetHeight + 40; }
|
||||
return false;
|
||||
}
|
||||
let lastT = performance.now(), lastDyn = 0, lastSec = 0, running = true;
|
||||
function frame(now) {
|
||||
if (!running) return;
|
||||
const dt = Math.min(0.1, Math.max(0.001, (now - lastT) / 1000)); lastT = now;
|
||||
W.simMs = Date.now() + W.offsetH * 3600000; W.t = (W.simMs - NOW) / 1000; W.sun = astro.sun(W.simMs);
|
||||
Cam.update(dt);
|
||||
if (APP.mode === 'situation' && !Cam.anim && GE.sheet.targetShift != null && !APP.isWide()) Cam.shiftY += (GE.sheet.targetShift - Cam.shiftY) * Math.min(1, dt * 7);
|
||||
if (globeVisible()) {
|
||||
const [cw, chh] = sizeCanvas();
|
||||
if (now - lastDyn > 40) { W.updateDynamic(); lastDyn = now; }
|
||||
const view = Cam.view(cw, chh); GE.mainView = view;
|
||||
const ov = GE.renderOverride || {};
|
||||
W.render(view, chain, null, { ...ov, focus: W.focus, extraSprites: W.activeExtras?.sprites, extraLines: W.activeExtras?.lines, fade: GE.introFade ?? 1 });
|
||||
APP.updateLabels(view); GE.ride.frame(view); GE.capture.frame(view);
|
||||
}
|
||||
if (APP.tab === 'globe' || APP.isWide() || APP.mode) GE.gui.ribbonFrame();
|
||||
GE.thumbs.step(APP.intro ? 14 : 8);
|
||||
if (now - lastSec > 1000) { lastSec = now; W.updateCounts(); GE.now.tick(); GE.sheet.tick(); GE.you.tick(); }
|
||||
governor(dt);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) running = false; else if (!running) { running = true; lastT = performance.now(); requestAnimationFrame(frame); } });
|
||||
|
||||
/* ---------- cold open ---------- */
|
||||
function coldOpen() {
|
||||
const cold = $('#cold'); const app = $('#app');
|
||||
const skipAll = HASH.nointro || HASH.tab;
|
||||
const red = prefersReduced();
|
||||
W.sun = astro.sun(Date.now()); const finalCam = APP.heroCam();
|
||||
const end = () => {
|
||||
if (!APP.intro) return; APP.intro = false; GE.introFade = 1; W.setTime(null);
|
||||
cold.classList.add('out'); app.classList.remove('intro'); setTimeout(() => cold.remove(), 1100);
|
||||
Cam.cancel(); if (Cam.range > 2.2 && !APP.isWide()) Cam.flyTo(finalCam, { duration: 0.6 });
|
||||
Cam.autoSpin = 0.3; APP.updateChrome(); APP.moveLens();
|
||||
if (HASH.tab) APP.setTab(HASH.tab, { force: true });
|
||||
};
|
||||
APP.intro = true; app.classList.add('intro'); APP.updateChrome();
|
||||
if (skipAll) { Cam.set(finalCam); end(); return; }
|
||||
Cam.set({ lat: 8, lon: finalCam.lon + 75, range: 5.8, heading: 0, tilt: 0, shiftY: 0.0, shiftX: 0 });
|
||||
const T = red ? 1.2 : 3.4; const t0 = performance.now();
|
||||
GE.introFade = 0;
|
||||
if (!red) Cam.flyTo(finalCam, { duration: T });
|
||||
else Cam.set(finalCam);
|
||||
const tick = () => {
|
||||
if (!APP.intro) return; const u = clamp((performance.now() - t0) / 1000 / T, 0, 1);
|
||||
GE.introFade = ease.out(clamp(u * 2.2, 0, 1));
|
||||
if (!red) W.setTime(Date.now() - (1 - ease.outQuint(u)) * 9 * 3600000);
|
||||
if (u > 0.12) cold.classList.add('s1'); if (u > 0.36) cold.classList.add('s2');
|
||||
if (u >= 1) { setTimeout(end, red ? 200 : 500); return; } requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); Cam.set(finalCam); end(); });
|
||||
cold.addEventListener('click', () => { Cam.set(finalCam); end(); });
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
async function boot() {
|
||||
const gl = GL.init(canvas);
|
||||
if (!gl) { document.body.insertAdjacentHTML('beforeend', '<div style="position:fixed;inset:0;display:grid;place-items:center;color:#fff;font:15px system-ui;padding:24px;text-align:center;background:#05070B">This vision needs WebGL2. Try a recent browser on a phone, tablet or desktop.</div>'); return; }
|
||||
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); running = false; APP.toast('Graphics context lost — reopen to resume', 4000); });
|
||||
await W.loadTextures();
|
||||
W.buildStatic(); W.buildMovers(); W.updateCounts(); W.updateDynamic();
|
||||
W.situationPins = GE.SIT.list.map(GE.SIT.pin).filter(Boolean);
|
||||
APP.initShell(); GE.now.render(); GE.threads.render(); GE.you.render(); GE.gui.init(); GE.sheet.initDrag();
|
||||
Cam.attach(canvas, () => GE.mainView || Cam.view(canvas.clientWidth, canvas.clientHeight));
|
||||
sizeCanvas();
|
||||
coldOpen();
|
||||
requestAnimationFrame(frame);
|
||||
GE.threads.init().then(() => { if (!GE.threads.ws) GE.threads.render(); });
|
||||
GE.crypto.KEY.get().then(() => GE.you.render());
|
||||
if (HASH.open) setTimeout(() => GE.sheet.open(HASH.open, {}), 300);
|
||||
GE.ready = true;
|
||||
}
|
||||
boot().catch((e) => { console.error(e); });
|
||||
Reference in new issue
Block a user