mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
wip(mobile): checkpoint the globe engine 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:
36 files changed
+10296
-623
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# Globe API: round-two additions
|
||||
|
||||
These additions extend DESIGN.md §10 and change no existing signature. Import them from `mobile/globe/index.js`. The doc comment at the top of that file repeats this contract.
|
||||
|
||||
## Viewer position
|
||||
|
||||
```js
|
||||
globe.setViewer({ lon, lat } | null) // where the person is: sets distanceKm and the ISS/Starlink "over you" cards
|
||||
globe.viewer() → { lon, lat } | null
|
||||
```
|
||||
|
||||
## Situations (extended)
|
||||
|
||||
```js
|
||||
globe.situations({ observer? }) → Promise<Situation[]>
|
||||
globe.omitted() → [{ genre, reason }] // genres the last call left out, and why
|
||||
globe.timings() → { [genre]: ms } // per-genre build time of the last call
|
||||
```
|
||||
|
||||
`Situation` keeps every field from round one (`id, kind, family, section, title, subtitle, place, lon, lat, camera, layers, time?, metrics, thumbnail, live, updatedAt, source, severity`) and adds these:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `band` | `'orbit' \| 'sky' \| 'ground' \| 'inside'` | The altitude band of the sensor. The engine never emits `inside`, because only the shell's own witness and room cards are inside. |
|
||||
| `archetype` | `'sensor' \| 'eye' \| 'track' \| 'number'` | The card layout. The shell adds `witness` and `room` cards itself. |
|
||||
| `telemetry` | `string` | A short uppercase strip, for example `SKY · FL206 · 332 KT · SQ 7700 · EZY58XM`, `GROUND · CAM 07450 · PICCADILLY CIRCUS · 02:16:14Z` or `ORBIT · ISS · 433 KM · 7.65 KM/S`. Draw it in HTML in a monospace face. |
|
||||
| `media?` | `{ kind:'image', url, refreshMs, cameraId, headingDeg?, provider?, city? }` | Eye cards only. `url` is a same-origin frame from `/api/cctv/frame/…` that was checked to be a real upstream image, not the proxy's synthetic fallback. For a fresh frame, call `globe.eyeFrameUrl(cameraId)` every `refreshMs`. |
|
||||
| `series?` | `[{ t, v }]` | Real history for number cards, and for some sensor cards. `t` is epoch ms. |
|
||||
| `seriesLabel?` | `string` | The quantity `v` measures, for example `'lightning-active area (km²) per 15 min'`. |
|
||||
| `headline?` | `{ value, unit, label }` | The one big number on a track or number card. |
|
||||
| `distanceKm?` | `number` | The great-circle distance from the viewer. It is present only once `setViewer` has been called, or when `observer` is passed. |
|
||||
|
||||
The `thumbnail` field is still a plain Esri still, so older code keeps working. New code calls `globe.thumbnail(situation)` instead.
|
||||
|
||||
The engine orders the cards by severity, then interleaves them so neighbouring cards differ in band and archetype where possible.
|
||||
|
||||
## Card compositor
|
||||
|
||||
```js
|
||||
globe.thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) → Promise<string /* blob: object URL */>
|
||||
```
|
||||
|
||||
The compositor builds each thumbnail as a JPEG in an offscreen 2D canvas, never in the Cesium viewer. It works in three layers:
|
||||
|
||||
1. **Base imagery.** Esri World Imagery tiles, which are CORS-clean, or NASA GIBS Black Marble for orbit cards.
|
||||
2. **Real overlays.** The data that describes the situation. Examples include NOAA IR with a cold-top enhancement, MRMS radar, lightning density dots by age, the NHC cone with the forecast track and 34/50/64-kt wind radii, aircraft with 2-minute velocity leaders, quake rings, fires with a smoke plume along the observed wind, and SGP4 ground tracks.
|
||||
3. **Grade.** A tint for the altitude band (orbit violet night, sky cool, ground amber) and a vignette.
|
||||
|
||||
The compositor draws no text and no brackets.
|
||||
|
||||
Eye cards return the camera frame itself, lightly graded. Results are cached in memory and, when the page is a secure context, in the Cache API, keyed by id, size and an update bucket. Eye cards refresh every 30 s; other cards follow `updatedAt` in 5-minute buckets. Tiles are shared across cards, and at most 3 cards render at once.
|
||||
|
||||
If an overlay fails, the card still gets its base picture. The promise rejects only when the base imagery or the camera frame fails. In that case, fall back to `situation.thumbnail` or to the shell's own placeholder.
|
||||
|
||||
## Pins (discourse and capture)
|
||||
|
||||
```js
|
||||
globe.addPin({ id, lon, lat, kind, headingDeg?, fovDeg = 60, rangeM = 350, color?, label?, altM? }) → id
|
||||
// kind: 'post' | 'witness' | 'eye' | 'live' | 'hologram'
|
||||
// 'live' and 'eye' pins draw a view-frustum wedge on the ground along headingDeg
|
||||
globe.removePin(id) → boolean
|
||||
globe.pins() → [{ id, lon, lat, kind, headingDeg, fovDeg, rangeM, color, label, altM }]
|
||||
```
|
||||
|
||||
- Picking a pin emits `'pick'` with `{ kind: 'pin', pinKind, id, title, subtitle, lon, lat, meta: { layer: 'pins', headingDeg, fovDeg, label } }`.
|
||||
- Pins appear in `globe.labels()` with `layer: 'pins'` and `pinKind`.
|
||||
- Live pins breathe at 1 Hz.
|
||||
|
||||
## Labels
|
||||
|
||||
Each entry from `globe.labels(max)` can now carry an optional `detail`, a short intensity tag:
|
||||
|
||||
- Cyclones: `CAT 2 · 85 KT · 974 HPA`
|
||||
- Emergency squawks: `SQ 7700`
|
||||
- Air quality: `AQI 152`
|
||||
|
||||
## Nearest public camera
|
||||
|
||||
```js
|
||||
globe.nearestEye(lon, lat, km = 50)
|
||||
→ Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg, imageUrl, city, provider } | null>
|
||||
globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
|
||||
```
|
||||
|
||||
`nearestEye` reads the CCTV catalog at `/api/cctv/sources`, which serves about 4,000 cameras across London, Toronto, Sydney, Seattle, the Bay Area, Los Angeles, Helsinki/Finland, Tallinn, Barcelona, Calgary, Norway and other places. It does not check the frame. A camera can still answer with the proxy's synthetic image when its upstream is down.
|
||||
|
||||
## Weather and storm layers (now available in `globe.layers()`)
|
||||
|
||||
| id | Family | Source | Time |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| `radar` | Weather | NOAA MRMS base reflectivity through nowCOAST (`/api/weather`), CONUS | Scrubbing replays about 50 min of 4-minute frames |
|
||||
| `clouds` | Weather | NOAA global longwave IR mosaic through nowCOAST, drawn as an unlit cloud layer (the alpha-keyed IR) | 6 h of hourly frames |
|
||||
| `lightning` | Weather | NOAA nowCOAST lightning density, 15 min (Americas and Pacific), with a flickering glow per active 0.5° cell | 3 h of 15-minute frames |
|
||||
| `wind` | Weather | 10 m wind, drawn with the desktop app's GPU streamline renderer. Uses `/api/wind` GFS when the server can decode GRIB, otherwise a 12° Open-Meteo grid | Live |
|
||||
| `jet` | Weather | Jet stream at 250 hPa from Open-Meteo, same renderer | Live |
|
||||
| `cyclones` | Hazards | Now also draws the filled NHC cone and 34/50/64-kt wind radii, parsed from the NHC TCM text at api.weather.gov | Forecast scrubbing, as before |
|
||||
| `air-quality` | Hazards | Open-Meteo Air Quality (CAMS model), PM2.5 AQI for 40 major cities | Live |
|
||||
| `gnss` | Infra | `/api/gnss-integrity` (adsb.lol NIC/NACp), shown as 0.5° cells in gpsjam.org bands. From orbit it samples the Baltic, Black Sea, Levant, Gulf and Gulf of Finland; close in, it samples the view | Live |
|
||||
| `transit` | Ground | `/api/transit` GTFS-realtime vehicles from the open feed nearest the view | Live, 20 s |
|
||||
|
||||
Rasters are drawn a few kilometres above the ground and unlit, so storms stay visible on the night side. When an upstream is down, the layer reports `status: 'error'` with a `message`.
|
||||
|
||||
When a weather layer is on, `layers()` entries can also carry `frames` (the number of frames available) and a `message` that gives the product title and the frame age.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #05070b;
|
||||
color: #dfe6f0;
|
||||
font:
|
||||
12px/1.35 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
#globe {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
#status {
|
||||
padding: 10px 14px;
|
||||
font:
|
||||
11px ui-monospace,
|
||||
monospace;
|
||||
color: #8aa0b8;
|
||||
}
|
||||
#sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, var(--cw, 216px));
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: var(--cw, 216px);
|
||||
}
|
||||
figure img {
|
||||
display: block;
|
||||
width: var(--cw, 216px);
|
||||
height: var(--ch, 288px);
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
figure .tm {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 52px;
|
||||
font:
|
||||
600 8.5px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
figcaption {
|
||||
padding: 4px 2px 0;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
}
|
||||
figcaption b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
figcaption small {
|
||||
color: #8aa0b8;
|
||||
}
|
||||
.err {
|
||||
color: #ff6a3d;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Globe cards · contact sheet</title>
|
||||
<link rel="stylesheet" href="./contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="globe"></div>
|
||||
<header id="status">building situations…</header>
|
||||
<main id="sheet"></main>
|
||||
<script type="module" src="./contact.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
// Contact sheet: every live situation with its composited thumbnail.
|
||||
// Open /mobile/globe/contact.html (optionally ?lat=&lon= for a viewer).
|
||||
import { createGlobe } from './index.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const status = document.getElementById('status');
|
||||
const sheet = document.getElementById('sheet');
|
||||
const globe = await createGlobe(document.getElementById('globe'), {
|
||||
layers: [],
|
||||
terrain: false,
|
||||
});
|
||||
window.globe = globe;
|
||||
if (params.has('lat'))
|
||||
globe.setViewer({
|
||||
lat: Number(params.get('lat')),
|
||||
lon: Number(params.get('lon')),
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const only = params.get('only');
|
||||
const all = await globe.situations();
|
||||
const list = only
|
||||
? all.filter((s) => only.split(',').some((o) => s.id.startsWith(o)))
|
||||
: all;
|
||||
const cw = Number(params.get('w') || 216);
|
||||
const ch = Math.round((cw * 4) / 3);
|
||||
document.documentElement.style.setProperty('--cw', `${cw}px`);
|
||||
document.documentElement.style.setProperty('--ch', `${ch}px`);
|
||||
window.situations = list;
|
||||
status.textContent = `${list.length} situations in ${Math.round(performance.now() - t0)} ms · rendering thumbnails…`;
|
||||
const t1 = performance.now();
|
||||
let done = 0;
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const fig = document.createElement('figure');
|
||||
const img = document.createElement('img');
|
||||
const tm = document.createElement('div');
|
||||
tm.className = 'tm';
|
||||
tm.textContent = s.telemetry || '';
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.innerHTML = `<b></b><small></small>`;
|
||||
cap.querySelector('b').textContent = s.title;
|
||||
cap.querySelector('small').textContent =
|
||||
`${s.band}/${s.archetype} · ${s.subtitle}`;
|
||||
fig.append(img, tm, cap);
|
||||
sheet.append(fig);
|
||||
try {
|
||||
img.src = await globe.thumbnail(s, { width: cw, height: ch, dpr: 2 });
|
||||
} catch (error) {
|
||||
cap.querySelector('small').className = 'err';
|
||||
cap.querySelector('small').textContent =
|
||||
`thumbnail failed: ${error.message}`;
|
||||
}
|
||||
done++;
|
||||
}),
|
||||
);
|
||||
status.textContent = `${list.length} situations · thumbnails in ${Math.round(performance.now() - t1)} ms · omitted: ${(globe.omitted?.() || []).map((o) => `${o.genre} (${o.reason})`).join('; ')}`;
|
||||
console.log('timings', JSON.stringify(window.globe.timings?.()));
|
||||
document.body.dataset.ready = '1';
|
||||
+1
-1
@@ -48,7 +48,7 @@ const drawLabels = () => {
|
||||
labelLayer.replaceChildren(
|
||||
...globe.labels(40).map((l) => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = l.title;
|
||||
el.textContent = l.detail ? `${l.title} · ${l.detail}` : l.title;
|
||||
el.style.left = `${l.x}px`;
|
||||
el.style.top = `${l.y}px`;
|
||||
return el;
|
||||
|
||||
@@ -89,7 +89,7 @@ const REGISTRATION_CALLSIGN =
|
||||
/^(N\d{1,5}[A-Z]{0,2}|[CDFGI]-?[A-Z]{4}|VH-?[A-Z]{3}|D[A-Z]{4}|ZK-?[A-Z]{3}|JA\d{3,4}[A-Z]?)$/;
|
||||
|
||||
/** OpenSky state vector → flight record. */
|
||||
function flightFromState(s) {
|
||||
export function flightFromState(s) {
|
||||
const lon = finite(s[5]);
|
||||
const lat = finite(s[6]);
|
||||
if (lon === null || lat === null) return null;
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
/**
|
||||
* Round-two feeds: weather rasters (NOAA nowCOAST through the app's
|
||||
* /api/weather proxy), wind fields (/api/wind GFS, else Open-Meteo), the jet
|
||||
* stream (Open-Meteo 250 hPa), NHC wind radii (api.weather.gov TCM text,
|
||||
* CORS-enabled), GNSS integrity (/api/gnss-integrity), GTFS-realtime transit
|
||||
* (/api/transit), air quality (Open-Meteo) and regional ADS-B scans.
|
||||
*
|
||||
* Every loader is keyless. Each returns real data or throws; nothing here
|
||||
* invents a value when an upstream is down.
|
||||
*/
|
||||
import { createFeed, FeedError } from './feeds.js';
|
||||
import { finite } from './geo.js';
|
||||
|
||||
const TIMEOUT_MS = 25_000;
|
||||
|
||||
async function getJson(url, { timeoutMs = TIMEOUT_MS, ...init } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok)
|
||||
throw new FeedError(`HTTP ${response.status} for ${url.split('?')[0]}`, {
|
||||
status: response.status,
|
||||
});
|
||||
return await response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small keyed cache of single-flight loads (per anchor / per feed id). */
|
||||
export function createKeyedFeed(name, load, { ttlMs = 60_000, max = 32 } = {}) {
|
||||
const entries = new Map();
|
||||
return {
|
||||
name,
|
||||
peek(key) {
|
||||
return entries.get(key)?.data ?? null;
|
||||
},
|
||||
entries() {
|
||||
return [...entries.values()].filter((e) => e.data);
|
||||
},
|
||||
async get(key, args, { maxAgeMs = ttlMs } = {}) {
|
||||
let e = entries.get(key);
|
||||
if (e?.data && Date.now() - e.at < maxAgeMs) return e.data;
|
||||
if (e?.error && Date.now() - e.failedAt < 30_000) {
|
||||
if (e.data) return e.data;
|
||||
throw e.error;
|
||||
}
|
||||
if (!e) {
|
||||
e = { key, data: null, at: 0, inflight: null, error: null };
|
||||
entries.set(key, e);
|
||||
while (entries.size > max) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
if (!e.inflight)
|
||||
e.inflight = (async () => {
|
||||
try {
|
||||
e.data = await load(args ?? key);
|
||||
e.at = Date.now();
|
||||
e.error = null;
|
||||
} catch (error) {
|
||||
e.error = error;
|
||||
e.failedAt = Date.now();
|
||||
} finally {
|
||||
e.inflight = null;
|
||||
}
|
||||
})();
|
||||
await e.inflight;
|
||||
if (!e.data) throw e.error || new FeedError(`${name} unavailable`);
|
||||
return e.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Weather rasters ───────────────────────── */
|
||||
|
||||
export const WEATHER_TILE_LEVEL_MAX = 6;
|
||||
|
||||
/** nowCOAST manifest: { product, times[], latest, bounds, title, attribution }. */
|
||||
export function createWeatherManifestFeed(product) {
|
||||
return createFeed(
|
||||
`weather:${product}`,
|
||||
async () => {
|
||||
const m = await getJson(`/api/weather/manifest?product=${product}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (m?.unavailable || !Array.isArray(m?.times) || !m.times.length)
|
||||
throw new FeedError(m?.reason || `${product} imagery unavailable`);
|
||||
return {
|
||||
data: {
|
||||
product,
|
||||
times: m.times,
|
||||
latest: m.latest,
|
||||
bounds: m.bounds,
|
||||
title: m.title,
|
||||
coverage: m.coverage,
|
||||
description: m.description,
|
||||
attribution: m.attribution,
|
||||
source: `NOAA nowCOAST · ${m.title}`,
|
||||
},
|
||||
stale: Boolean(m.stale),
|
||||
};
|
||||
},
|
||||
{ ttlMs: product === 'clouds' ? 10 * 60_000 : 2 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Same-origin whole-extent image URL for a frame (2:1 sizes only). */
|
||||
export function weatherImageUrl(product, time, width = 2048) {
|
||||
const size = width === 4096 ? '' : `&size=${width}x${width / 2}`;
|
||||
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${size}`;
|
||||
}
|
||||
|
||||
/** Same-origin geographic tile URL (z ≤ 6; 2×1 tiles at level 0). */
|
||||
export function weatherTileUrl(product, time, z, x, y) {
|
||||
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z=${z}&x=${x}&y=${y}`;
|
||||
}
|
||||
|
||||
/** Latest frame at or before `ms` (the latest when ms is null). */
|
||||
export function frameAt(times, ms) {
|
||||
if (!times?.length) return null;
|
||||
if (ms == null) return times.at(-1);
|
||||
let best = null;
|
||||
for (const t of times) if (Date.parse(t) <= ms + 60_000) best = t;
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind ───────────────────────── */
|
||||
|
||||
const MS_PER_KMH = 1 / 3.6;
|
||||
|
||||
/** u/v (m/s) from a meteorological speed (km/h) and from-direction (deg). */
|
||||
function uv(speedKmh, dirDeg) {
|
||||
const s = speedKmh * MS_PER_KMH;
|
||||
const r = (dirDeg * Math.PI) / 180;
|
||||
return [-s * Math.sin(r), -s * Math.cos(r)];
|
||||
}
|
||||
|
||||
/** A coarse global grid of Open-Meteo points, north to south, 12° spacing
|
||||
* (390 points: one request stays under Open-Meteo's per-minute budget). */
|
||||
function omGrid(step = 12, north = 72, south = -72) {
|
||||
const lats = [];
|
||||
const lons = [];
|
||||
for (let la = north; la >= south; la -= step)
|
||||
for (let lo = -180; lo < 180; lo += step) {
|
||||
lats.push(la);
|
||||
lons.push(lo);
|
||||
}
|
||||
return {
|
||||
lats,
|
||||
lons,
|
||||
nx: 360 / step,
|
||||
ny: (north - south) / step + 1,
|
||||
lo1: -180,
|
||||
la1: north,
|
||||
dx: step,
|
||||
dy: step,
|
||||
};
|
||||
}
|
||||
|
||||
const OM_CACHE_MS = 60 * 60_000;
|
||||
|
||||
/** Grids persist per browser for an hour: Open-Meteo counts every point. */
|
||||
function readGridCache(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const j = JSON.parse(raw);
|
||||
if (Date.now() - j.at > OM_CACHE_MS) return null;
|
||||
const dec = (b64) =>
|
||||
new Float32Array(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer,
|
||||
);
|
||||
return {
|
||||
field: { ...j.grid, u: dec(j.u), v: dec(j.v) },
|
||||
speed: dec(j.s),
|
||||
time: j.time,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGridCache(key, value) {
|
||||
try {
|
||||
const enc = (arr) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(arr.buffer)));
|
||||
const { u, v, ...grid } = value.field;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
at: Date.now(),
|
||||
time: value.time,
|
||||
grid,
|
||||
u: enc(u),
|
||||
v: enc(v),
|
||||
s: enc(value.speed),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* storage full or blocked */
|
||||
}
|
||||
}
|
||||
|
||||
async function openMeteoField(opts = {}) {
|
||||
const key = `gev-om-grid:${opts.hourlyLevel || '10m'}:${opts.step || 12}`;
|
||||
const cached = readGridCache(key);
|
||||
if (cached) return cached;
|
||||
const value = await fetchOpenMeteoField(opts);
|
||||
writeGridCache(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchOpenMeteoField({ hourlyLevel = null, step = 12 } = {}) {
|
||||
const g = omGrid(step);
|
||||
const vars = hourlyLevel
|
||||
? `hourly=wind_speed_${hourlyLevel},wind_direction_${hourlyLevel}&forecast_hours=1`
|
||||
: 'current=wind_speed_10m,wind_direction_10m';
|
||||
// Three parallel chunks: a cold pressure-level request for the whole grid
|
||||
// can take Open-Meteo half a minute; thirds come back in seconds.
|
||||
const chunk = Math.ceil(g.lats.length / 3);
|
||||
const parts = await Promise.all(
|
||||
[0, 1, 2].map((k) => {
|
||||
const la = g.lats.slice(k * chunk, (k + 1) * chunk);
|
||||
const lo = g.lons.slice(k * chunk, (k + 1) * chunk);
|
||||
return getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${la.join(',')}&longitude=${lo.join(',')}&${vars}&timezone=GMT`,
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const rows = parts.flat();
|
||||
if (rows.length !== g.lats.length)
|
||||
throw new FeedError('Malformed Open-Meteo wind grid');
|
||||
const n = rows.length;
|
||||
const u = new Float32Array(n);
|
||||
const v = new Float32Array(n);
|
||||
const speed = new Float32Array(n);
|
||||
let time = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = rows[i];
|
||||
const s = hourlyLevel
|
||||
? r.hourly?.[`wind_speed_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_speed_10m;
|
||||
const d = hourlyLevel
|
||||
? r.hourly?.[`wind_direction_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_direction_10m;
|
||||
time ??= hourlyLevel ? r.hourly?.time?.[0] : r.current?.time;
|
||||
if (!Number.isFinite(s) || !Number.isFinite(d)) continue;
|
||||
[u[i], v[i]] = uv(s, d);
|
||||
speed[i] = s * MS_PER_KMH;
|
||||
}
|
||||
return {
|
||||
field: {
|
||||
u,
|
||||
v,
|
||||
nx: g.nx,
|
||||
ny: g.ny,
|
||||
lo1: g.lo1,
|
||||
la1: g.la1,
|
||||
dx: g.dx,
|
||||
dy: g.dy,
|
||||
},
|
||||
speed,
|
||||
time: time ? Date.parse(`${time}Z`) : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (10 m) wind: the app's GFS grid when the server can decode it,
|
||||
* otherwise a 12° Open-Meteo grid (GFS/ICON blend, keyless).
|
||||
*/
|
||||
export function createWindFeed() {
|
||||
return createFeed(
|
||||
'wind',
|
||||
async () => {
|
||||
try {
|
||||
const m = await getJson('/api/wind/manifest?model=gfs', {
|
||||
cache: 'no-store',
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
if (!m?.unavailable && m?.grid && m?.gridUrl) {
|
||||
const response = await fetch(m.gridUrl);
|
||||
if (response.ok) {
|
||||
const count = m.grid.nx * m.grid.ny;
|
||||
const values = new Float32Array(await response.arrayBuffer());
|
||||
if (values.length >= count * 2) {
|
||||
const u = values.slice(0, count);
|
||||
const v = values.slice(count, count * 2);
|
||||
return {
|
||||
data: {
|
||||
field: { ...m.grid, u, v },
|
||||
level: '10 m',
|
||||
time: m.fetchedAt || Date.now(),
|
||||
source: 'NOAA GFS 10 m wind',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to Open-Meteo */
|
||||
}
|
||||
const om = await openMeteoField();
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '10 m',
|
||||
source: 'Open-Meteo forecast API (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Jet stream: Open-Meteo 250 hPa (≈ FL340) wind on a 12° grid. */
|
||||
export function createJetFeed() {
|
||||
return createFeed(
|
||||
'jet',
|
||||
async () => {
|
||||
const om = await openMeteoField({ hourlyLevel: '250hPa' });
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '250 hPa',
|
||||
source: 'Open-Meteo forecast API · 250 hPa (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** One point's current 10 m wind (for smoke plumes). */
|
||||
export const pointWind = createKeyedFeed(
|
||||
'point-wind',
|
||||
async ({ lon, lat }) => {
|
||||
// The app's own regional weather route first (server-side, cached).
|
||||
try {
|
||||
const r = await getJson(
|
||||
`/api/weather-effects?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
const w = r?.weather;
|
||||
if (Number.isFinite(w?.windKph) && Number.isFinite(w?.windDirectionDeg))
|
||||
return {
|
||||
speedKmh: w.windKph,
|
||||
fromDeg: w.windDirectionDeg,
|
||||
gustKmh: null,
|
||||
time: Date.parse(w.observedAt) || Date.now(),
|
||||
source: 'Open-Meteo via /api/weather-effects',
|
||||
};
|
||||
} catch {
|
||||
/* direct below */
|
||||
}
|
||||
const j = await getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=wind_speed_10m,wind_direction_10m,wind_gusts_10m&timezone=GMT`,
|
||||
);
|
||||
const c = j?.current;
|
||||
if (!Number.isFinite(c?.wind_speed_10m))
|
||||
throw new FeedError('No point wind');
|
||||
return {
|
||||
speedKmh: c.wind_speed_10m,
|
||||
fromDeg: c.wind_direction_10m,
|
||||
gustKmh: c.wind_gusts_10m,
|
||||
time: Date.parse(`${c.time}Z`),
|
||||
source: 'Open-Meteo',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000, max: 64 },
|
||||
);
|
||||
|
||||
/** Bilinear sample of a north-to-south, seam-wrapped field. */
|
||||
export function sampleField(field, lon, lat) {
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((((lon - lo1) % 360) + 360) % 360) / dx;
|
||||
const y = Math.max(0, Math.min(ny - 1, (la1 - lat) / dy));
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const x1 = (x0 + 1) % nx;
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const at = (arr, xi, yi) => arr[yi * nx + (xi % nx)];
|
||||
const lerp = (arr) =>
|
||||
(at(arr, x0, y0) * (1 - tx) + at(arr, x1, y0) * tx) * (1 - ty) +
|
||||
(at(arr, x0, y1) * (1 - tx) + at(arr, x1, y1) * tx) * ty;
|
||||
return { u: lerp(u), v: lerp(v) };
|
||||
}
|
||||
|
||||
/* ───────────────────────── NHC wind radii ───────────────────────── */
|
||||
|
||||
const NM_KM = 1.852;
|
||||
|
||||
/** Parse an NHC TCM (forecast/advisory) for the current position's radii. */
|
||||
export function parseTcm(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const name =
|
||||
/(?:HURRICANE|TROPICAL STORM|TROPICAL DEPRESSION|SUBTROPICAL STORM|POST-TROPICAL CYCLONE|POTENTIAL TROPICAL CYCLONE)\s+([A-Z-]+)/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
const center =
|
||||
/CENTER LOCATED NEAR\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])\s+AT\s+(\d\d)\/(\d\d)(\d\d)Z/.exec(
|
||||
text,
|
||||
);
|
||||
if (!center) return null;
|
||||
const lat = Number(center[1]) * (center[2] === 'S' ? -1 : 1);
|
||||
const lon = Number(center[3]) * (center[4] === 'W' ? -1 : 1);
|
||||
// The first radii block after "MAX SUSTAINED WINDS" is the current one.
|
||||
const block = text.split(/MAX SUSTAINED WINDS/)[1]?.split(/REPEAT\.\.\./)[0];
|
||||
const radii = [];
|
||||
for (const m of (block || '').matchAll(
|
||||
/(\d\d) KT\.+\s*(\d+)NE\s+(\d+)SE\s+(\d+)SW\s+(\d+)NW/g,
|
||||
)) {
|
||||
radii.push({
|
||||
kt: Number(m[1]),
|
||||
// NE, SE, SW, NW in km
|
||||
quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM),
|
||||
});
|
||||
}
|
||||
const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
|
||||
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
||||
return {
|
||||
name: name || null,
|
||||
lat,
|
||||
lon,
|
||||
radii,
|
||||
pressureHpa: pressure ? Number(pressure) : null,
|
||||
windKt: maxWind ? Number(maxWind) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Latest TCM per active storm from api.weather.gov (CORS-enabled, keyless). */
|
||||
export function createTcmFeed() {
|
||||
return createFeed(
|
||||
'nhc-tcm',
|
||||
async () => {
|
||||
const list = await getJson('https://api.weather.gov/products/types/TCM', {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
});
|
||||
const graph = list?.['@graph'] || [];
|
||||
const now = Date.now();
|
||||
const latest = new Map();
|
||||
for (const p of graph) {
|
||||
const t = Date.parse(p.issuanceTime);
|
||||
if (!Number.isFinite(t) || now - t > 18 * 3600_000) continue;
|
||||
const key = p.wmoCollectiveId;
|
||||
if (!latest.has(key)) latest.set(key, p);
|
||||
}
|
||||
const storms = [];
|
||||
await Promise.all(
|
||||
[...latest.values()].slice(0, 8).map(async (p) => {
|
||||
try {
|
||||
const prod = await getJson(
|
||||
`https://api.weather.gov/products/${p.id}`,
|
||||
{ headers: { Accept: 'application/ld+json' } },
|
||||
);
|
||||
const parsed = parseTcm(prod?.productText);
|
||||
if (parsed)
|
||||
storms.push({
|
||||
...parsed,
|
||||
issuedAt: Date.parse(p.issuanceTime),
|
||||
wmo: p.wmoCollectiveId,
|
||||
});
|
||||
} catch {
|
||||
/* one advisory failing drops only its radii */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
storms,
|
||||
source: 'NOAA NHC forecast/advisory via api.weather.gov',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS integrity ───────────────────────── */
|
||||
|
||||
/** Well-known interference regions worth sampling (anchors, whole degrees). */
|
||||
export const GNSS_ANCHORS = Object.freeze([
|
||||
{ id: 'baltic', name: 'Baltic Sea', lat: 56, lon: 21 },
|
||||
{ id: 'black-sea', name: 'Black Sea', lat: 45, lon: 33 },
|
||||
{ id: 'levant', name: 'Eastern Mediterranean', lat: 34, lon: 34 },
|
||||
{ id: 'gulf', name: 'Persian Gulf', lat: 27, lon: 52 },
|
||||
{ id: 'finland', name: 'Gulf of Finland', lat: 60, lon: 26 },
|
||||
]);
|
||||
|
||||
/** A cell is "anomalous" by the gpsjam band formula on aircraft counts. */
|
||||
function gnssCells(rows, cellDeg = 0.5) {
|
||||
const cells = new Map();
|
||||
for (const r of rows) {
|
||||
const key = `${Math.floor(r.lat / cellDeg)}:${Math.floor(r.lon / cellDeg)}`;
|
||||
let c = cells.get(key);
|
||||
if (!c)
|
||||
cells.set(
|
||||
key,
|
||||
(c = {
|
||||
lat: (Math.floor(r.lat / cellDeg) + 0.5) * cellDeg,
|
||||
lon: (Math.floor(r.lon / cellDeg) + 0.5) * cellDeg,
|
||||
total: 0,
|
||||
bad: 0,
|
||||
}),
|
||||
);
|
||||
c.total++;
|
||||
if (r.degraded || r.gpsLost) c.bad++;
|
||||
}
|
||||
const out = [];
|
||||
for (const c of cells.values()) {
|
||||
if (c.total < 3) continue;
|
||||
c.percent = (100 * Math.max(0, c.bad - 1)) / c.total;
|
||||
c.level = c.percent >= 10 ? 'high' : c.percent >= 2 ? 'medium' : 'low';
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const gnssIntegrity = createKeyedFeed(
|
||||
'gnss',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/gnss-integrity?lat=${Math.round(lat)}&lon=${Math.round(lon)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.rows)) throw new FeedError('Malformed GNSS snapshot');
|
||||
const rows = j.rows.filter(
|
||||
(r) => Number.isFinite(r.lat) && Number.isFinite(r.lon),
|
||||
);
|
||||
return {
|
||||
anchor: j.anchor,
|
||||
rows,
|
||||
cells: gnssCells(rows),
|
||||
degraded: rows.filter((r) => r.degraded || r.gpsLost).length,
|
||||
fetchedAt: j.fetchedAt || Date.now(),
|
||||
source: 'adsb.lol NIC/NACp via /api/gnss-integrity',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 60_000, max: 24 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
export function createTransitCatalogFeed() {
|
||||
return createFeed(
|
||||
'transit-feeds',
|
||||
async () => {
|
||||
const j = await getJson('/api/transit/feeds');
|
||||
if (!Array.isArray(j?.feeds)) throw new FeedError('No transit catalog');
|
||||
return { data: { feeds: j.feeds, source: 'GTFS-realtime agencies' } };
|
||||
},
|
||||
{ ttlMs: 6 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export const transitVehicles = createKeyedFeed(
|
||||
'transit',
|
||||
async (feedId) => {
|
||||
const j = await getJson(
|
||||
`/api/transit/vehicles/${encodeURIComponent(feedId)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.vehicles)) throw new FeedError('No vehicles');
|
||||
return {
|
||||
feedId,
|
||||
name: j.name,
|
||||
fetchedAt: j.fetchedAt,
|
||||
vehicles: j.vehicles
|
||||
.filter((v) => Number.isFinite(v.lat) && Number.isFinite(v.lon))
|
||||
.map((v) => ({
|
||||
id: String(v.id),
|
||||
lat: v.lat,
|
||||
lon: v.lon,
|
||||
bearing: finite(v.bearing),
|
||||
speedMps: finite(v.speedMps),
|
||||
routeId: v.routeId || '',
|
||||
status: v.status || '',
|
||||
t: (v.timestamp || 0) * 1000,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ ttlMs: 20_000, max: 8 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
export const AQ_CITIES = Object.freeze([
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Chengdu', 104.07, 30.67],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Paris', 2.35, 48.86],
|
||||
['London', -0.13, 51.51],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Milan', 9.19, 45.46],
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
]);
|
||||
|
||||
export function createAirQualityFeed() {
|
||||
return createFeed(
|
||||
'air-quality',
|
||||
async () => {
|
||||
const lats = AQ_CITIES.map((c) => c[2]).join(',');
|
||||
const lons = AQ_CITIES.map((c) => c[1]).join(',');
|
||||
const rows = await getJson(
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lats}&longitude=${lons}¤t=us_aqi_pm2_5,pm2_5&hourly=pm2_5&past_days=1&forecast_days=1&timezone=GMT`,
|
||||
{ timeoutMs: 40_000 },
|
||||
);
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
if (list.length !== AQ_CITIES.length)
|
||||
throw new FeedError('Malformed air-quality response');
|
||||
const now = Date.now();
|
||||
const records = [];
|
||||
list.forEach((r, i) => {
|
||||
const aqi = r?.current?.us_aqi_pm2_5;
|
||||
if (!Number.isFinite(aqi)) return;
|
||||
const times = r.hourly?.time || [];
|
||||
const pm = r.hourly?.pm2_5 || [];
|
||||
const series = [];
|
||||
for (let k = 0; k < times.length; k++) {
|
||||
const t = Date.parse(`${times[k]}Z`);
|
||||
if (t <= now && now - t <= 26 * 3600_000 && Number.isFinite(pm[k]))
|
||||
series.push({ t, v: pm[k] });
|
||||
}
|
||||
records.push({
|
||||
id: `aq-${AQ_CITIES[i][0]}`,
|
||||
name: AQ_CITIES[i][0],
|
||||
lon: AQ_CITIES[i][1],
|
||||
lat: AQ_CITIES[i][2],
|
||||
aqi,
|
||||
pm25: r.current.pm2_5,
|
||||
series,
|
||||
t: Date.parse(`${r.current.time}Z`),
|
||||
});
|
||||
});
|
||||
if (!records.length) throw new FeedError('No air-quality values');
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source: 'Open-Meteo Air Quality API (CAMS forecast model)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function aqiColor(aqi) {
|
||||
if (aqi <= 50) return '#5BD46B';
|
||||
if (aqi <= 100) return '#F2D33B';
|
||||
if (aqi <= 150) return '#FF9A3D';
|
||||
if (aqi <= 200) return '#FF4D4D';
|
||||
if (aqi <= 300) return '#B04DFF';
|
||||
return '#8C1A3A';
|
||||
}
|
||||
|
||||
/* ───────────────────────── Regional ADS-B ───────────────────────── */
|
||||
|
||||
/**
|
||||
* The flights proxy answers with OpenSky's global snapshot, or (when
|
||||
* OpenSky is down) a 250 nm adsb.lol snapshot around the anchor. Either way
|
||||
* the records near the anchor are real; this cache keeps one per anchor.
|
||||
*/
|
||||
export function createRegionalFlights(flightFromState) {
|
||||
return createKeyedFeed(
|
||||
'regional-flights',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/flights?lat=${lat.toFixed(3)}&lon=${lon.toFixed(3)}`,
|
||||
{ cache: 'no-store', timeoutMs: 30_000 },
|
||||
);
|
||||
if (!Array.isArray(j?.states))
|
||||
throw new FeedError('Malformed flight snapshot');
|
||||
const records = [];
|
||||
for (const s of j.states) {
|
||||
const r = flightFromState(s);
|
||||
if (r) records.push(r);
|
||||
}
|
||||
return { records, time: (j.time || 0) * 1000 };
|
||||
},
|
||||
{ ttlMs: 30_000, max: 12 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* A small bundled gazetteer for naming where something is ("38 km SW of
|
||||
* Miami"). Coordinates are public city centres, rounded.
|
||||
*/
|
||||
import { haversineKm } from './geo.js';
|
||||
|
||||
/** US metros (radar coverage) — [name, lon, lat]. */
|
||||
export const US_METROS = Object.freeze([
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['Phoenix', -112.07, 33.45],
|
||||
['Philadelphia', -75.17, 39.95],
|
||||
['San Antonio', -98.49, 29.42],
|
||||
['San Diego', -117.16, 32.72],
|
||||
['Dallas', -96.8, 32.78],
|
||||
['Austin', -97.74, 30.27],
|
||||
['Jacksonville', -81.66, 30.33],
|
||||
['Columbus', -83.0, 39.96],
|
||||
['Charlotte', -80.84, 35.23],
|
||||
['Indianapolis', -86.16, 39.77],
|
||||
['San Francisco', -122.42, 37.77],
|
||||
['Seattle', -122.33, 47.61],
|
||||
['Denver', -104.99, 39.74],
|
||||
['Washington', -77.04, 38.9],
|
||||
['Nashville', -86.78, 36.16],
|
||||
['Oklahoma City', -97.52, 35.47],
|
||||
['El Paso', -106.49, 31.76],
|
||||
['Boston', -71.06, 42.36],
|
||||
['Portland', -122.68, 45.52],
|
||||
['Las Vegas', -115.14, 36.17],
|
||||
['Detroit', -83.05, 42.33],
|
||||
['Memphis', -90.05, 35.15],
|
||||
['Louisville', -85.76, 38.25],
|
||||
['Baltimore', -76.61, 39.29],
|
||||
['Milwaukee', -87.91, 43.04],
|
||||
['Albuquerque', -106.65, 35.08],
|
||||
['Tucson', -110.97, 32.22],
|
||||
['Atlanta', -84.39, 33.75],
|
||||
['Kansas City', -94.58, 39.1],
|
||||
['Miami', -80.19, 25.76],
|
||||
['Tampa', -82.46, 27.95],
|
||||
['Orlando', -81.38, 28.54],
|
||||
['New Orleans', -90.07, 29.95],
|
||||
['Minneapolis', -93.27, 44.98],
|
||||
['Cleveland', -81.69, 41.5],
|
||||
['St. Louis', -90.2, 38.63],
|
||||
['Pittsburgh', -79.99, 40.44],
|
||||
['Cincinnati', -84.51, 39.1],
|
||||
['Raleigh', -78.64, 35.78],
|
||||
['Salt Lake City', -111.89, 40.76],
|
||||
['Birmingham', -86.8, 33.52],
|
||||
['Omaha', -95.94, 41.26],
|
||||
['Buffalo', -78.88, 42.89],
|
||||
['Richmond', -77.44, 37.54],
|
||||
['Corpus Christi', -97.4, 27.8],
|
||||
['Mobile', -88.04, 30.69],
|
||||
['Little Rock', -92.29, 34.75],
|
||||
['Des Moines', -93.61, 41.59],
|
||||
['Wichita', -97.34, 37.69],
|
||||
['Boise', -116.2, 43.62],
|
||||
['Spokane', -117.43, 47.66],
|
||||
['Billings', -108.5, 45.78],
|
||||
['Fargo', -96.79, 46.88],
|
||||
['Charleston', -79.93, 32.78],
|
||||
['Savannah', -81.1, 32.08],
|
||||
['Tallahassee', -84.28, 30.44],
|
||||
['Shreveport', -93.75, 32.53],
|
||||
['Amarillo', -101.83, 35.22],
|
||||
['Lubbock', -101.86, 33.58],
|
||||
['Brownsville', -97.5, 25.9],
|
||||
['Key West', -81.78, 24.56],
|
||||
]);
|
||||
|
||||
/** World places and seas for "near X" phrasing — [name, lon, lat]. */
|
||||
export const WORLD_PLACES = Object.freeze([
|
||||
...US_METROS,
|
||||
['Toronto', -79.38, 43.65],
|
||||
['Montréal', -73.57, 45.5],
|
||||
['Vancouver', -123.12, 49.28],
|
||||
['Calgary', -114.07, 51.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Guadalajara', -103.35, 20.67],
|
||||
['Monterrey', -100.31, 25.69],
|
||||
['Mérida', -89.62, 20.97],
|
||||
['Cancún', -86.85, 21.16],
|
||||
['Puerto Vallarta', -105.23, 20.65],
|
||||
['Mazatlán', -106.41, 23.25],
|
||||
['Cabo San Lucas', -109.92, 22.89],
|
||||
['Acapulco', -99.88, 16.85],
|
||||
['Guatemala City', -90.51, 14.63],
|
||||
['San José', -84.09, 9.93],
|
||||
['Panama City', -79.52, 8.98],
|
||||
['Havana', -82.37, 23.11],
|
||||
['Santo Domingo', -69.93, 18.49],
|
||||
['San Juan', -66.1, 18.47],
|
||||
['Kingston', -76.79, 18.02],
|
||||
['Caracas', -66.9, 10.48],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Medellín', -75.56, 6.24],
|
||||
['Quito', -78.47, -0.18],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Manaus', -60.02, -3.12],
|
||||
['Belém', -48.5, -1.46],
|
||||
['Brasília', -47.88, -15.79],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Rio de Janeiro', -43.17, -22.91],
|
||||
['Buenos Aires', -58.38, -34.6],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['La Paz', -68.15, -16.5],
|
||||
['Asunción', -57.58, -25.26],
|
||||
['Reykjavík', -21.9, 64.15],
|
||||
['London', -0.13, 51.51],
|
||||
['Paris', 2.35, 48.86],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Lisbon', -9.14, 38.72],
|
||||
['Rome', 12.5, 41.9],
|
||||
['Berlin', 13.4, 52.52],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Stockholm', 18.07, 59.33],
|
||||
['Helsinki', 24.94, 60.17],
|
||||
['Tallinn', 24.75, 59.44],
|
||||
['Riga', 24.11, 56.95],
|
||||
['Vilnius', 25.28, 54.69],
|
||||
['Kaliningrad', 20.51, 54.71],
|
||||
['Gdańsk', 18.65, 54.35],
|
||||
['St Petersburg', 30.32, 59.94],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Kyiv', 30.52, 50.45],
|
||||
['Odesa', 30.73, 46.48],
|
||||
['Sevastopol', 33.52, 44.62],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Athens', 23.73, 37.98],
|
||||
['Nicosia', 33.38, 35.19],
|
||||
['Beirut', 35.5, 33.89],
|
||||
['Tel Aviv', 34.78, 32.09],
|
||||
['Damascus', 36.29, 33.51],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Baghdad', 44.36, 33.31],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Kuwait City', 47.98, 29.38],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Doha', 51.53, 25.29],
|
||||
['Dubai', 55.27, 25.2],
|
||||
['Muscat', 58.4, 23.59],
|
||||
['Bandar Abbas', 56.27, 27.18],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Chennai', 80.27, 13.08],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
['Colombo', 79.86, 6.93],
|
||||
['Yangon', 96.2, 16.87],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Ho Chi Minh City', 106.63, 10.82],
|
||||
['Kuala Lumpur', 101.69, 3.14],
|
||||
['Singapore', 103.82, 1.35],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Surabaya', 112.75, -7.25],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Cebu', 123.89, 10.32],
|
||||
['Davao', 125.61, 7.07],
|
||||
['Hong Kong', 114.17, 22.32],
|
||||
['Taipei', 121.56, 25.03],
|
||||
['Guangzhou', 113.26, 23.13],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Busan', 129.08, 35.18],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Osaka', 135.5, 34.69],
|
||||
['Okinawa', 127.68, 26.21],
|
||||
['Sapporo', 141.35, 43.06],
|
||||
['Guam', 144.79, 13.44],
|
||||
['Port Moresby', 147.18, -9.44],
|
||||
['Darwin', 130.84, -12.46],
|
||||
['Cairns', 145.77, -16.92],
|
||||
['Brisbane', 153.03, -27.47],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Melbourne', 144.96, -37.81],
|
||||
['Perth', 115.86, -31.95],
|
||||
['Auckland', 174.76, -36.85],
|
||||
['Suva', 178.44, -18.14],
|
||||
['Nouméa', 166.46, -22.28],
|
||||
['Honolulu', -157.86, 21.31],
|
||||
['Anchorage', -149.9, 61.22],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Accra', -0.19, 5.6],
|
||||
['Dakar', -17.47, 14.72],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Nairobi', 36.82, -1.29],
|
||||
['Addis Ababa', 38.75, 9.03],
|
||||
['Khartoum', 32.53, 15.5],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Cape Town', 18.42, -33.92],
|
||||
['Antananarivo', 47.52, -18.88],
|
||||
['Maputo', 32.57, -25.97],
|
||||
['Luanda', 13.23, -8.84],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Almaty', 76.89, 43.24],
|
||||
['Novosibirsk', 82.93, 55.03],
|
||||
['Vladivostok', 131.89, 43.12],
|
||||
]);
|
||||
|
||||
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
|
||||
/** Compass bearing (deg) from a to b. */
|
||||
export function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
|
||||
const x =
|
||||
Math.cos(lat1 * r) * Math.sin(lat2 * r) -
|
||||
Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
|
||||
return (((Math.atan2(y, x) / r) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function compass(deg) {
|
||||
return DIRS[Math.round((((deg % 360) + 360) % 360) / 45) % 8];
|
||||
}
|
||||
|
||||
/** Nearest bundled place: { name, km, dir } (dir is from the place). */
|
||||
export function nearestPlace(lat, lon, list = WORLD_PLACES) {
|
||||
let best = null;
|
||||
for (const [name, plon, plat] of list) {
|
||||
const km = haversineKm(lat, lon, plat, plon);
|
||||
if (!best || km < best.km) best = { name, km, plon, plat };
|
||||
}
|
||||
if (!best) return null;
|
||||
return {
|
||||
name: best.name,
|
||||
km: Math.round(best.km),
|
||||
dir: compass(bearingDeg(best.plat, best.plon, lat, lon)),
|
||||
};
|
||||
}
|
||||
|
||||
/** "Miami", "38 km SW of Miami" or "640 km E of Manila". */
|
||||
export function describePlace(lat, lon, list) {
|
||||
const p = nearestPlace(lat, lon, list);
|
||||
if (!p) return '';
|
||||
if (p.km < 15) return p.name;
|
||||
return `${p.km.toLocaleString('en-US')} km ${p.dir} of ${p.name}`;
|
||||
}
|
||||
@@ -237,6 +237,117 @@ const painters = {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
},
|
||||
bolt(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(28, 3);
|
||||
ctx.lineTo(12, 27);
|
||||
ctx.lineTo(23, 27);
|
||||
ctx.lineTo(19, 45);
|
||||
ctx.lineTo(37, 18);
|
||||
ctx.lineTo(26, 18);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
aq(ctx, s) {
|
||||
const g = ctx.createRadialGradient(s / 2, s / 2, 4, s / 2, s / 2, s / 2);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.9)');
|
||||
g.addColorStop(0.45, 'rgba(255,255,255,0.45)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
bus(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 4);
|
||||
ctx.lineTo(33, 14);
|
||||
ctx.lineTo(33, 42);
|
||||
ctx.lineTo(15, 42);
|
||||
ctx.lineTo(15, 14);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
post(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 10);
|
||||
ctx.lineTo(40, 10);
|
||||
ctx.lineTo(40, 32);
|
||||
ctx.lineTo(22, 32);
|
||||
ctx.lineTo(13, 41);
|
||||
ctx.lineTo(14, 32);
|
||||
ctx.lineTo(8, 32);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
witness(ctx) {
|
||||
// a seal: a rosette with a check
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a = (i / 24) * Math.PI * 2;
|
||||
const r = i % 2 ? 17 : 21;
|
||||
ctx.lineTo(24 + r * Math.cos(a), 24 + r * Math.sin(a));
|
||||
}
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, 24);
|
||||
ctx.lineTo(22, 30);
|
||||
ctx.lineTo(33, 18);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.75)';
|
||||
ctx.stroke();
|
||||
},
|
||||
eye(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(4, 24);
|
||||
ctx.quadraticCurveTo(24, 4, 44, 24);
|
||||
ctx.quadraticCurveTo(24, 44, 4, 24);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fill();
|
||||
},
|
||||
live(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
for (const r of [15, 21]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, r, -0.7, 0.7);
|
||||
ctx.moveTo(24 - r * Math.cos(0.7), 24 - r * Math.sin(0.7));
|
||||
ctx.arc(24, 24, r, Math.PI - 0.7, Math.PI + 0.7);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
hologram(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 3);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.lineTo(42, 34);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.lineTo(6, 34);
|
||||
ctx.lineTo(6, 14);
|
||||
ctx.closePath();
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(6, 14);
|
||||
ctx.lineTo(24, 25);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.moveTo(24, 25);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.stroke();
|
||||
},
|
||||
};
|
||||
|
||||
/** `{ image, imageId }` for a named glyph. */
|
||||
|
||||
+188
-5
@@ -6,6 +6,30 @@
|
||||
* an adaptive pixel-ratio governor, a single-full-res-pass post chain, and
|
||||
* live layers drawn with batched billboards and polylines (never Entities,
|
||||
* never Cesium labels).
|
||||
*
|
||||
* Round-two contract additions (full notes in ./API.md):
|
||||
*
|
||||
* globe.setViewer({ lon, lat } | null) / globe.viewer()
|
||||
* globe.situations({ observer? }) → Promise<Situation[]>, each Situation now
|
||||
* also has band: 'orbit'|'sky'|'ground'|'inside',
|
||||
* archetype: 'sensor'|'eye'|'track'|'number', telemetry: string,
|
||||
* media?: { kind:'image', url, refreshMs, cameraId, headingDeg? },
|
||||
* series?: [{ t, v }], seriesLabel?, headline?: { value, unit, label },
|
||||
* distanceKm? (once a viewer is known)
|
||||
* globe.omitted() → [{ genre, reason }] globe.timings() → { genre: ms }
|
||||
* globe.thumbnail(situation, { width=360, height=480, dpr=2 })
|
||||
* → Promise<string> (blob: URL of a composited JPEG; never the viewer)
|
||||
* globe.addPin({ id, lon, lat, kind:'post'|'witness'|'eye'|'live'|'hologram',
|
||||
* headingDeg?, fovDeg?, rangeM?, color?, label?, altM? }) → id
|
||||
* globe.removePin(id) → boolean globe.pins() → Pin[]
|
||||
* 'pick' → { kind:'pin', pinKind, id, title, subtitle, lon, lat, meta }
|
||||
* globe.nearestEye(lon, lat, km=50)
|
||||
* → Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg,
|
||||
* imageUrl, city, provider } | null>
|
||||
* globe.eyeFrameUrl(cameraId) → string | null
|
||||
* labels() entries may carry `detail` ('CAT 2 · 85 KT · 974 HPA', 'SQ 7700')
|
||||
* New layers: radar, clouds, lightning, wind, jet, air-quality, gnss,
|
||||
* transit; cyclones gain a filled cone and NHC wind radii.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createImagery } from './imagery.js';
|
||||
@@ -30,6 +54,19 @@ import {
|
||||
createTleFeed,
|
||||
createVesselsFeed,
|
||||
} from './feeds.js';
|
||||
import {
|
||||
createAirQualityFeed,
|
||||
createJetFeed,
|
||||
createRegionalFlights,
|
||||
createTcmFeed,
|
||||
createTransitCatalogFeed,
|
||||
createWeatherManifestFeed,
|
||||
createWindFeed,
|
||||
} from './feedsWeather.js';
|
||||
import { cctvFrameUrl, flightFromState } from './feeds.js';
|
||||
import { createPins } from './pins.js';
|
||||
import { createCompositor } from './compositor.js';
|
||||
import { haversineKm } from './geo.js';
|
||||
import { applyModelAtmosphereWorkaround } from '../../src/app/atmosphereCompat.js';
|
||||
|
||||
export { LENSES, FAMILIES };
|
||||
@@ -46,6 +83,7 @@ export const DEFAULT_LAYERS = Object.freeze([
|
||||
|
||||
const LABEL_PRIORITY = [
|
||||
'squawk',
|
||||
'air-quality',
|
||||
'iss',
|
||||
'cyclones',
|
||||
'launches',
|
||||
@@ -67,6 +105,7 @@ const LABEL_PRIORITY = [
|
||||
|
||||
const PLANETARY_LABELS = new Set([
|
||||
'squawk',
|
||||
'air-quality',
|
||||
'iss',
|
||||
'cyclones',
|
||||
'launches',
|
||||
@@ -93,6 +132,14 @@ const DATA_CREDITS = {
|
||||
floods:
|
||||
'Bhote Koshi: GeoPera river centerline (CC BY-NC 4.0), Vantor (Maxar) Open Data',
|
||||
cctv: 'Cameras: public agency traffic camera feeds (see per-camera provider)',
|
||||
radar: 'Radar: NOAA/NWS MRMS via nowCOAST',
|
||||
clouds: 'Satellite IR: NOAA/NESDIS via nowCOAST',
|
||||
lightning: 'Lightning: NOAA/NWS nowCOAST (Vaisala NLDN/GLD360)',
|
||||
wind: 'Wind: NOAA GFS / Open-Meteo',
|
||||
jet: 'Jet stream: Open-Meteo (250 hPa)',
|
||||
'air-quality': 'Air quality: Open-Meteo / Copernicus CAMS',
|
||||
gnss: 'GNSS integrity: adsb.lol (ODbL), gpsjam.org method',
|
||||
transit: 'Transit: agency GTFS-realtime feeds (see per-feed credit)',
|
||||
};
|
||||
|
||||
function viewerOptions(creditContainer, options) {
|
||||
@@ -266,6 +313,15 @@ export async function createGlobe(container, options = {}) {
|
||||
cyclones: createCyclonesFeed(eonet),
|
||||
cctv: createCctvFeed(),
|
||||
flood: createFloodFeed(),
|
||||
radar: createWeatherManifestFeed('radar'),
|
||||
clouds: createWeatherManifestFeed('clouds'),
|
||||
lightning: createWeatherManifestFeed('lightning'),
|
||||
wind: createWindFeed(),
|
||||
jet: createJetFeed(),
|
||||
tcm: createTcmFeed(),
|
||||
airQuality: createAirQualityFeed(),
|
||||
transitCatalog: createTransitCatalogFeed(),
|
||||
regionalFlights: createRegionalFlights(flightFromState),
|
||||
};
|
||||
|
||||
let layersTimer = null;
|
||||
@@ -285,6 +341,24 @@ export async function createGlobe(container, options = {}) {
|
||||
},
|
||||
};
|
||||
const layers = buildLayers(ctx, feeds);
|
||||
const pins = createPins({ scene, requestRender });
|
||||
const compositor = createCompositor({ feeds });
|
||||
// Slow keyless sources warm in the background so the first feed has them.
|
||||
setTimeout(() => {
|
||||
for (const f of [
|
||||
feeds.jet,
|
||||
feeds.tcm,
|
||||
feeds.airQuality,
|
||||
feeds.cctv,
|
||||
feeds.lightning,
|
||||
feeds.radar,
|
||||
feeds.clouds,
|
||||
])
|
||||
f.get().catch(() => {});
|
||||
}, 1500);
|
||||
let viewerPos = opts.observer
|
||||
? { lon: opts.observer.lon, lat: opts.observer.lat }
|
||||
: null;
|
||||
|
||||
// ── Selection visuals ────────────────────────────────────────────
|
||||
const selectionRoot = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
@@ -437,7 +511,15 @@ export async function createGlobe(container, options = {}) {
|
||||
handler.setInputAction((movement) => {
|
||||
const picked = scene.pick(movement.position, 30, 30);
|
||||
const id = picked?.id;
|
||||
if (id && typeof id === 'object' && id.layer && layers.has(id.layer)) {
|
||||
if (id && typeof id === 'object' && id.layer === 'pins') {
|
||||
if (selected) api.select(null);
|
||||
emit('pick', pins.pick(id.key));
|
||||
} else if (
|
||||
id &&
|
||||
typeof id === 'object' &&
|
||||
id.layer &&
|
||||
layers.has(id.layer)
|
||||
) {
|
||||
api.select({ layer: id.layer, id: id.key });
|
||||
emit('pick', selected?.pick ?? null);
|
||||
} else {
|
||||
@@ -458,6 +540,7 @@ export async function createGlobe(container, options = {}) {
|
||||
drawSelection();
|
||||
moved = true;
|
||||
}
|
||||
if (pins.tick()) moved = true;
|
||||
if (moved) requestRender();
|
||||
}, 1000);
|
||||
|
||||
@@ -751,10 +834,92 @@ export async function createGlobe(container, options = {}) {
|
||||
},
|
||||
|
||||
situations(extra = {}) {
|
||||
return buildSituations(
|
||||
{ feeds, timeMs },
|
||||
{ observer: extra.observer ?? opts.observer },
|
||||
);
|
||||
const observer = extra.observer ?? viewerPos ?? opts.observer;
|
||||
return buildSituations({ feeds, timeMs }, { observer }).then((list) => {
|
||||
if (observer && Number.isFinite(observer.lat))
|
||||
for (const s of list)
|
||||
s.distanceKm = Math.round(
|
||||
haversineKm(observer.lat, observer.lon, s.lat, s.lon),
|
||||
);
|
||||
return list;
|
||||
});
|
||||
},
|
||||
|
||||
/** Genres the last situations() call left out, and why (for QA/UI). */
|
||||
omitted() {
|
||||
return buildSituations.lastOmitted || [];
|
||||
},
|
||||
|
||||
/** Per-genre build times (ms since the feeds resolved) of the last call. */
|
||||
timings() {
|
||||
return buildSituations.lastTimings || {};
|
||||
},
|
||||
|
||||
/** Where the person is (for distanceKm and "near you"); null clears. */
|
||||
setViewer(pos) {
|
||||
viewerPos =
|
||||
pos && Number.isFinite(pos.lon) && Number.isFinite(pos.lat)
|
||||
? { lon: pos.lon, lat: pos.lat }
|
||||
: null;
|
||||
emit('viewer', viewerPos);
|
||||
},
|
||||
|
||||
viewer() {
|
||||
return viewerPos ? { ...viewerPos } : null;
|
||||
},
|
||||
|
||||
/** Card compositor: Promise<object URL> (see API.md). */
|
||||
thumbnail(situation, options) {
|
||||
return compositor.thumbnail(situation, options);
|
||||
},
|
||||
|
||||
addPin(pin) {
|
||||
return pins.add(pin);
|
||||
},
|
||||
|
||||
removePin(id) {
|
||||
return pins.remove(id);
|
||||
},
|
||||
|
||||
pins() {
|
||||
return pins.list();
|
||||
},
|
||||
|
||||
/** Nearest public camera within `km`, from the CCTV catalog. */
|
||||
async nearestEye(lon, lat, km = 50) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
let data;
|
||||
try {
|
||||
data = await feeds.cctv.get();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let best = null;
|
||||
for (const c of data.records) {
|
||||
if (Math.abs(c.lat - lat) > km / 100 + 0.5) continue;
|
||||
const d = haversineKm(lat, lon, c.lat, c.lon);
|
||||
if (d <= km && (!best || d < best.d)) best = { c, d };
|
||||
}
|
||||
if (!best) return null;
|
||||
const c = best.c;
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
distanceKm: Math.round(best.d * 100) / 100,
|
||||
headingDeg: c.heading,
|
||||
fovDeg: c.fovDeg,
|
||||
imageUrl: cctvFrameUrl(c),
|
||||
city: c.city,
|
||||
provider: c.provider,
|
||||
};
|
||||
},
|
||||
|
||||
/** A fresh same-origin frame URL for a catalog camera (or null). */
|
||||
eyeFrameUrl(cameraId) {
|
||||
const c = feeds.cctv.data?.records.find((r) => r.id === cameraId);
|
||||
return c ? cctvFrameUrl(c) : null;
|
||||
},
|
||||
|
||||
credits() {
|
||||
@@ -803,6 +968,7 @@ export async function createGlobe(container, options = {}) {
|
||||
if (!glyphs) continue;
|
||||
// Semantic zoom for words too: from orbit only the headline layers.
|
||||
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
|
||||
if (layer.id === 'lightning') continue; // the glow is the label
|
||||
const rank = LABEL_PRIORITY.indexOf(layer.id);
|
||||
for (let i = 0; i < glyphs.length; i++) {
|
||||
const bb = glyphs.get(i);
|
||||
@@ -821,6 +987,19 @@ export async function createGlobe(container, options = {}) {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < pins.billboards.length; i++) {
|
||||
const bb = pins.billboards.get(i);
|
||||
if (!occluder.isPointVisible(bb.position)) continue;
|
||||
const p = scene.cartesianToCanvasCoordinates(bb.position, scratch);
|
||||
if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue;
|
||||
candidates.push({
|
||||
layer: { id: 'pins', pick: (key) => pins.pick(key) },
|
||||
key: bb.id.key,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
score: -50,
|
||||
});
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
// Greedy placement: a label never overlaps one already placed.
|
||||
const placed = [];
|
||||
@@ -854,6 +1033,8 @@ export async function createGlobe(container, options = {}) {
|
||||
layer: c.layer.id,
|
||||
kind: pick.kind,
|
||||
title: pick.title,
|
||||
...(pick.labelDetail ? { detail: pick.labelDetail } : {}),
|
||||
...(pick.pinKind ? { pinKind: pick.pinKind } : {}),
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
});
|
||||
@@ -886,6 +1067,8 @@ export async function createGlobe(container, options = {}) {
|
||||
removeMoveEnd();
|
||||
handler.destroy();
|
||||
for (const layer of layers.values()) layer.destroy();
|
||||
pins.destroy();
|
||||
compositor.destroy();
|
||||
governor.destroy();
|
||||
post.destroy();
|
||||
viewer.destroy();
|
||||
|
||||
@@ -176,6 +176,7 @@ export class AircraftLayer extends Layer {
|
||||
.join(' · '),
|
||||
lon,
|
||||
lat,
|
||||
...(r.emergency ? { labelDetail: `SQ ${r.squawk}` } : {}),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
icao24: r.id,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSkyLayers } from './air.js';
|
||||
import { createOrbitLayers } from './orbit.js';
|
||||
import { createHazardLayers } from './hazards.js';
|
||||
import { createSeaLayers, createEyesLayers } from './sea.js';
|
||||
import { createWeatherLayers } from './weather.js';
|
||||
|
||||
export const FAMILIES = Object.freeze([
|
||||
'Sky',
|
||||
@@ -62,7 +63,8 @@ export const CATALOG = Object.freeze([
|
||||
['Weather', 'radar', 'Radar'],
|
||||
['Weather', 'clouds', 'Clouds'],
|
||||
['Weather', 'lightning', 'Lightning'],
|
||||
['Weather', 'wind', 'Wind'],
|
||||
['Weather', 'wind', 'Wind (10 m)'],
|
||||
['Weather', 'jet', 'Jet stream (250 hPa)'],
|
||||
['Weather', 'forecast', 'Forecast models'],
|
||||
['Infra', 'datacenters', 'Datacenters'],
|
||||
['Infra', 'dams', 'Dams'],
|
||||
@@ -81,6 +83,7 @@ export function buildLayers(ctx, feeds) {
|
||||
...createOrbitLayers(ctx, feeds),
|
||||
...createHazardLayers(ctx, feeds),
|
||||
...createEyesLayers(ctx, feeds),
|
||||
...createWeatherLayers(ctx, feeds),
|
||||
])
|
||||
live.set(layer.id, layer);
|
||||
const out = new Map();
|
||||
|
||||
@@ -224,7 +224,7 @@ export function createHazardLayers(ctx, feeds) {
|
||||
return [
|
||||
quakes,
|
||||
fires,
|
||||
new CycloneLayer(ctx, { feed: feeds.cyclones }),
|
||||
new CycloneLayer(ctx, { feed: feeds.cyclones, tcm: feeds.tcm }),
|
||||
new FloodLayer(ctx, { feed: feeds.flood }),
|
||||
volcanoes,
|
||||
];
|
||||
@@ -255,8 +255,55 @@ export function stormPositionAt(storm, ms) {
|
||||
return null; // beyond the forecast
|
||||
}
|
||||
|
||||
const SAFFIR = [
|
||||
[137, 'CAT 5'],
|
||||
[113, 'CAT 4'],
|
||||
[96, 'CAT 3'],
|
||||
[83, 'CAT 2'],
|
||||
[64, 'CAT 1'],
|
||||
[34, 'TS'],
|
||||
[0, 'TD'],
|
||||
];
|
||||
|
||||
/** Saffir-Simpson category (or TS/TD) for a 1-min sustained wind in kt. */
|
||||
export function stormCategory(windKt) {
|
||||
if (!Number.isFinite(windKt)) return '';
|
||||
return SAFFIR.find(([kt]) => windKt >= kt)[1];
|
||||
}
|
||||
|
||||
/** Ring of a quadrant wind-radii shape ([NE, SE, SW, NW] km) around a point. */
|
||||
export function radiiRing(lon, lat, quadrantsKm, stepDeg = 6) {
|
||||
const ring = [];
|
||||
const cosLat = Math.max(0.05, Math.cos((lat * Math.PI) / 180));
|
||||
for (let a = 0; a <= 360; a += stepDeg) {
|
||||
const q = Math.min(3, Math.floor((a % 360) / 90));
|
||||
const r = quadrantsKm[q];
|
||||
const rad = (a * Math.PI) / 180;
|
||||
ring.push([
|
||||
lon + (r * Math.sin(rad)) / (111.32 * cosLat),
|
||||
lat + (r * Math.cos(rad)) / 111.32,
|
||||
]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
export const RADII_COLORS = { 34: '#FFD84D', 50: '#FF9A3D', 64: '#FF3B30' };
|
||||
|
||||
/** Match an NHC TCM (wind radii) to a storm by position (≤ 250 km). */
|
||||
export function radiiFor(storm, tcm) {
|
||||
let best = null;
|
||||
for (const t of tcm?.storms || []) {
|
||||
const d = Math.hypot(
|
||||
(t.lon - storm.lon) * Math.cos((storm.lat * Math.PI) / 180),
|
||||
t.lat - storm.lat,
|
||||
);
|
||||
if (d < 2.3 && (!best || d < best.d)) best = { d, t };
|
||||
}
|
||||
return best?.t || null;
|
||||
}
|
||||
|
||||
class CycloneLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
constructor(ctx, { feed, tcm }) {
|
||||
super(ctx, {
|
||||
id: 'cyclones',
|
||||
family: 'Hazards',
|
||||
@@ -264,6 +311,8 @@ class CycloneLayer extends Layer {
|
||||
pollMs: 15 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.tcm = tcm;
|
||||
this.fills = null;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFFFFF'),
|
||||
@@ -284,6 +333,64 @@ class CycloneLayer extends Layer {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
this._draw(true);
|
||||
// Wind radii arrive from a second source; the cone and track never wait.
|
||||
this.tcm
|
||||
?.get()
|
||||
.then(() => this.on && this._fills(this.feed.data?.records || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Filled cone of uncertainty and 34/50/64-kt wind-radii quadrants. */
|
||||
_fills(storms) {
|
||||
if (this.fills) this.root.remove(this.fills);
|
||||
this.fills = null;
|
||||
const instances = [];
|
||||
const poly = (ring, css, alpha, height) => {
|
||||
if (!ring || ring.length < 4) return;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(css, alpha),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (const s of storms) {
|
||||
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
||||
const tcm = radiiFor(s, this.tcm?.data);
|
||||
if (tcm) s.radii = tcm.radii;
|
||||
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
|
||||
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
||||
poly(
|
||||
radiiRing(s.lon, s.lat, r.quadrantsKm),
|
||||
RADII_COLORS[r.kt] || '#FFD84D',
|
||||
r.kt === 64 ? 0.26 : r.kt === 50 ? 0.18 : 0.12,
|
||||
2500 + r.kt * 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!instances.length) return;
|
||||
this.fills = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
_draw(full) {
|
||||
@@ -308,7 +415,10 @@ class CycloneLayer extends Layer {
|
||||
}
|
||||
this.count = items.length;
|
||||
this.renderer.setItems(items);
|
||||
if (full) this._lines(data.records);
|
||||
if (full) {
|
||||
this._lines(data.records);
|
||||
this._fills(data.records);
|
||||
}
|
||||
}
|
||||
|
||||
_lines(storms) {
|
||||
@@ -402,9 +512,17 @@ class CycloneLayer extends Layer {
|
||||
.join(' · '),
|
||||
lon: s.now.lon,
|
||||
lat: s.now.lat,
|
||||
labelDetail: [
|
||||
stormCategory(s.now.windKt),
|
||||
s.now.windKt ? `${Math.round(s.now.windKt)} KT` : null,
|
||||
s.pressureHpa ? `${s.pressureHpa} HPA` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
windKt: s.now.windKt ?? null,
|
||||
radii: s.radii ?? null,
|
||||
pressureHpa: s.pressureHpa ?? null,
|
||||
movement: s.movement ?? null,
|
||||
basin: s.basin,
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* Weather family (radar, satellite IR clouds, lightning, surface wind, jet
|
||||
* stream) plus the round-two Ground/Infra/Hazards layers that share the same
|
||||
* plumbing (transit vehicles, GNSS interference, air quality).
|
||||
*
|
||||
* Rasters come from NOAA nowCOAST through the app's /api/weather proxy as
|
||||
* whole-extent frames and are drawn as one unlit, translucent rectangle each
|
||||
* (a few kilometres above the ground, where the weather is), so the night
|
||||
* side does not swallow them the way lit imagery layers would. Scrubbing the
|
||||
* time ribbon swaps to the frame valid at that time (radar ≈ 50 min of 4-min
|
||||
* frames, lightning 3 h of 15-min frames, IR 6 h of hourly frames). Wind uses
|
||||
* the desktop app's GPU streamline renderer (src/layers/wind/gpuRendering.js).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ContactRenderer,
|
||||
Layer,
|
||||
color,
|
||||
FAMILY_COLORS,
|
||||
inRect,
|
||||
} from './base.js';
|
||||
import { createWindGpuRendering } from '../../../src/layers/wind/gpuRendering.js';
|
||||
import {
|
||||
aqiColor,
|
||||
frameAt,
|
||||
gnssIntegrity,
|
||||
GNSS_ANCHORS,
|
||||
transitVehicles,
|
||||
weatherImageUrl,
|
||||
} from '../feedsWeather.js';
|
||||
import { haversineKm, relTime } from '../geo.js';
|
||||
|
||||
const WX = FAMILY_COLORS.Weather;
|
||||
|
||||
/* ───────────────────────── Frame decoding (shared) ───────────────────────── */
|
||||
|
||||
const frameCache = new Map(); // url → Promise<canvas>
|
||||
const FRAME_CACHE_MAX = 8;
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets an alpha key). */
|
||||
async function decodeFrame(url, product) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Weather frame HTTP ${response.status}`);
|
||||
const bitmap = await createImageBitmap(await response.blob());
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
if (product === 'clouds' || product === 'clouds-regional') {
|
||||
// Key the IR to clouds: warm surfaces clear, cloud tops white with a
|
||||
// soft ramp (the repo's cloud key is tuned for a lit globe; this one
|
||||
// also has to read on the unlit night side).
|
||||
const px = g.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const d = px.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const l = d[i];
|
||||
const t = Math.max(0, Math.min(1, (l - 95) / 95));
|
||||
const a = t * t * (3 - 2 * t);
|
||||
d[i + 3] = (d[i + 3] * a * 215) / 255;
|
||||
// Unlit clouds must not blow out under bloom: grey-white body,
|
||||
// coldest tops leaning icy blue.
|
||||
const cold = Math.max(0, (l - 200) / 55);
|
||||
d[i] = 168 + 42 * a - 22 * cold;
|
||||
d[i + 1] = 176 + 40 * a - 6 * cold;
|
||||
d[i + 2] = 192 + 36 * a + 10 * cold;
|
||||
}
|
||||
g.putImageData(px, 0, 0);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function cached(url, make, keep) {
|
||||
let p = frameCache.get(url);
|
||||
if (p) {
|
||||
frameCache.delete(url);
|
||||
frameCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
p = make();
|
||||
p.catch(() => frameCache.delete(url));
|
||||
if (!keep) return p;
|
||||
frameCache.set(url, p);
|
||||
while (frameCache.size > FRAME_CACHE_MAX)
|
||||
frameCache.delete(frameCache.keys().next().value);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets a cloud key). */
|
||||
export function loadWeatherFrame(
|
||||
product,
|
||||
time,
|
||||
width = 2048,
|
||||
{ keep = true } = {},
|
||||
) {
|
||||
const url = weatherImageUrl(product, time, width);
|
||||
return cached(url, () => decodeFrame(url, product), keep);
|
||||
}
|
||||
|
||||
/** A 2:1 detail window (0.25° grid) covering a view rect inside bounds. */
|
||||
export function detailWindow(rect, bounds) {
|
||||
if (!rect || rect.west > rect.east) return null;
|
||||
const lonSpan = rect.east - rect.west;
|
||||
const latSpan = rect.north - rect.south;
|
||||
let width = Math.max(1, lonSpan * 1.1, latSpan * 2.2);
|
||||
width = Math.ceil(width / 0.5) * 0.5;
|
||||
const height = width / 2;
|
||||
if (width >= bounds.east - bounds.west - 0.5) return null;
|
||||
if (height >= bounds.north - bounds.south - 0.5) return null;
|
||||
const snap = (v) => Math.round(v / 0.25) * 0.25;
|
||||
const cLon = (rect.west + rect.east) / 2;
|
||||
const cLat = (rect.south + rect.north) / 2;
|
||||
const minW = Math.ceil(bounds.west / 0.25) * 0.25;
|
||||
const maxW = Math.floor((bounds.east - width) / 0.25) * 0.25;
|
||||
const minS = Math.ceil(bounds.south / 0.25) * 0.25;
|
||||
const maxS = Math.floor((bounds.north - height) / 0.25) * 0.25;
|
||||
const west = Math.max(minW, Math.min(maxW, snap(cLon - width / 2)));
|
||||
const south = Math.max(minS, Math.min(maxS, snap(cLat - height / 2)));
|
||||
return { west, south, east: west + width, north: south + height };
|
||||
}
|
||||
|
||||
/** Decode a detail window of a frame (2048×1024). */
|
||||
export function loadWeatherDetail(product, time, box) {
|
||||
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=${box.west},${box.south},${box.east},${box.north}&size=2048x1024`;
|
||||
return cached(url, () => decodeFrame(url, product), true);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Raster layer ───────────────────────── */
|
||||
|
||||
class WeatherRasterLayer extends Layer {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {object} def
|
||||
* @param {{feed:object, product:string, heightM:number, alpha:number,
|
||||
* width?:number}} opts
|
||||
*/
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, { pollMs: 2 * 60_000, ...def });
|
||||
this.feed = opts.feed;
|
||||
this.product = opts.product;
|
||||
this.heightM = opts.heightM;
|
||||
this.alpha = opts.alpha;
|
||||
this.width = opts.width ?? 2048;
|
||||
this.primitive = null;
|
||||
this.material = null;
|
||||
this.frame = null;
|
||||
this._want = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
await this._apply();
|
||||
if (this.frame && this.feed.data)
|
||||
this.message = `${this.feed.data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
else if (this.feed.data?.coverage) this.message ||= this.feed.data.coverage;
|
||||
}
|
||||
|
||||
_ensurePrimitive(bounds) {
|
||||
if (this.primitive) return;
|
||||
this.material = Cesium.Material.fromType('Image', {
|
||||
image: Cesium.Material.DefaultImageId,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
const rect = Cesium.Rectangle.fromDegrees(
|
||||
bounds.west,
|
||||
bounds.south,
|
||||
bounds.east,
|
||||
bounds.north,
|
||||
);
|
||||
this.primitive = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: rect,
|
||||
height: this.heightM,
|
||||
granularity: Cesium.Math.toRadians(1),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material: this.material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async _apply() {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const time = frameAt(
|
||||
data.times,
|
||||
this.ctx.isLive() ? null : this.ctx.timeMs(),
|
||||
);
|
||||
this._want = time;
|
||||
if (!time) {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
this.count = 0;
|
||||
this.message = 'No observation at this time';
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
if (time === this.frame) {
|
||||
if (this.primitive) this.primitive.show = true;
|
||||
return;
|
||||
}
|
||||
this._ensurePrimitive(data.bounds);
|
||||
const canvas = await loadWeatherFrame(this.product, time, this.width);
|
||||
if (this._want !== time || !this.on) return;
|
||||
this.material.uniforms.image = canvas;
|
||||
this.primitive.show = true;
|
||||
this.frame = time;
|
||||
this.frameCanvas = canvas;
|
||||
this.frameTime = Date.parse(time);
|
||||
this.message = `${data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
if (this.afterFrame) this.afterFrame(canvas, data);
|
||||
else this.count = 1;
|
||||
this._detail().catch(() => {});
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
/** Close in, a sharper window of the same frame is laid over the global one. */
|
||||
onView(view) {
|
||||
this.lastView = view;
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._detailTimer);
|
||||
this._detailTimer = setTimeout(
|
||||
() => this._detail().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
350,
|
||||
);
|
||||
}
|
||||
|
||||
async _detail() {
|
||||
const v = this.lastView;
|
||||
const data = this.feed.data;
|
||||
const box =
|
||||
v && v.heightM < 3_500_000 && this.frame && data
|
||||
? detailWindow(v.rect, data.bounds)
|
||||
: null;
|
||||
if (!box) {
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
const key = `${this.frame}|${box.west},${box.south},${box.east},${box.north}`;
|
||||
if (key === this._detailKey) {
|
||||
if (this.detailPrim) this.detailPrim.show = true;
|
||||
return;
|
||||
}
|
||||
this._detailKey = key;
|
||||
const canvas = await loadWeatherDetail(this.product, this.frame, box);
|
||||
if (this._detailKey !== key || !this.on) return;
|
||||
if (this.detailPrim) this.root.remove(this.detailPrim);
|
||||
const material = Cesium.Material.fromType('Image', {
|
||||
image: canvas,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
this.detailPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
height: this.heightM + 60,
|
||||
granularity: Cesium.Math.toRadians(0.25),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
// The coarse global frame stays underneath only where the window ends.
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onTime() {
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._timeTimer);
|
||||
// Frames are whole-extent images: decode at most ~4 per second while
|
||||
// the ribbon is being dragged.
|
||||
this._timeTimer = setTimeout(
|
||||
() => this._apply().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
250,
|
||||
);
|
||||
}
|
||||
|
||||
onHide() {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.frame = null;
|
||||
}
|
||||
|
||||
info() {
|
||||
const i = super.info();
|
||||
if (this.on && this.feed.data) i.frames = this.feed.data.times.length;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Lightning ───────────────────────── */
|
||||
|
||||
const STRIKE_BIN_DEG = 0.5;
|
||||
const STRIKE_CAP = 1500;
|
||||
|
||||
/**
|
||||
* Strike-density raster plus a glow per active 0.5° cell, so storms read as
|
||||
* flickering points of light from orbit. nowCOAST's palette runs yellow →
|
||||
* orange → red → magenta with density; the glow grades the same way.
|
||||
*/
|
||||
class LightningLayer extends WeatherRasterLayer {
|
||||
constructor(ctx, opts) {
|
||||
super(
|
||||
ctx,
|
||||
{ id: 'lightning', family: 'Weather', name: 'Lightning' },
|
||||
{ ...opts, product: 'lightning', heightM: 9000, alpha: 0.95 },
|
||||
);
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFF2A8'),
|
||||
glyphName: 'bolt',
|
||||
rotate: false,
|
||||
lodHeightM: 1_500_000,
|
||||
cap: 300,
|
||||
dotScale: 0.55,
|
||||
glyphScale: 0.42,
|
||||
dotAlpha: 1,
|
||||
});
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
super.onView(view);
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
|
||||
afterFrame(canvas, data) {
|
||||
const cells = strikeCells(canvas, data.bounds);
|
||||
this.cells = new Map(cells.map((c) => [c.key, c]));
|
||||
this.count = cells.length;
|
||||
this.renderer.setItems(
|
||||
cells.map((c) => ({
|
||||
key: c.key,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
altM: 9500,
|
||||
color: color(
|
||||
c.level >= 3 ? '#FF8AE6' : c.level === 2 ? '#FFD27A' : '#FFF6C8',
|
||||
),
|
||||
dotScale: 0.32 + Math.min(0.55, c.pixels / 12),
|
||||
glyphScale: 0.8 + c.level * 0.12,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 Hz flicker: a few cells flare each second (cheap, no render loop). */
|
||||
onTick() {
|
||||
const n = this.renderer.dots.length;
|
||||
if (!n) return false;
|
||||
for (let k = 0; k < Math.min(24, n); k++) {
|
||||
const i = Math.floor(Math.random() * n);
|
||||
const bb = this.renderer.dots.get(i);
|
||||
bb.scale = bb._base ??= bb.scale;
|
||||
bb.scale = bb._base * (1.4 + Math.random() * 0.9);
|
||||
setTimeout(() => {
|
||||
if (this.renderer.dots.contains(bb)) bb.scale = bb._base;
|
||||
}, 220);
|
||||
}
|
||||
setTimeout(() => this.ctx.requestRender(), 230);
|
||||
return true;
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 9500] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'lightning',
|
||||
id: key,
|
||||
title: 'Lightning',
|
||||
subtitle: `Strike density class ${c.level} · ${relTime(this.frameTime, Date.now())}`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, level: c.level, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a nowCOAST lightning-density pixel 1 (low) .. 4 (extreme). */
|
||||
export function strikeLevel(r, g, b) {
|
||||
if (r > 200 && g > 200 && b < 140) return 1; // yellow
|
||||
if (r > 200 && g > 90) return 2; // orange
|
||||
if (r > 170 && b > 120) return 4; // magenta / pink
|
||||
if (r > 150) return 3; // red
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Bin the lit pixels of a whole-extent lightning frame into 0.5° cells. */
|
||||
export function strikeCells(canvas, bounds, bbox = null) {
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { width, height } = canvas;
|
||||
const px = g.getImageData(0, 0, width, height).data;
|
||||
const dLon = (bounds.east - bounds.west) / width;
|
||||
const dLat = (bounds.north - bounds.south) / height;
|
||||
const bins = new Map();
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = bounds.north - (y + 0.5) * dLat;
|
||||
if (bbox && (lat < bbox.south || lat > bbox.north)) continue;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (px[i + 3] < 40) continue;
|
||||
const lon = bounds.west + (x + 0.5) * dLon;
|
||||
if (bbox && (lon < bbox.west || lon > bbox.east)) continue;
|
||||
const level = strikeLevel(px[i], px[i + 1], px[i + 2]);
|
||||
const key = `${Math.floor(lat / STRIKE_BIN_DEG)}:${Math.floor(lon / STRIKE_BIN_DEG)}`;
|
||||
let c = bins.get(key);
|
||||
if (!c)
|
||||
bins.set(
|
||||
key,
|
||||
(c = { key, lat: 0, lon: 0, pixels: 0, level: 0, weight: 0 }),
|
||||
);
|
||||
c.lat += lat;
|
||||
c.lon += lon;
|
||||
c.pixels++;
|
||||
c.weight += level;
|
||||
c.level = Math.max(c.level, level);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const c of bins.values()) {
|
||||
c.lat /= c.pixels;
|
||||
c.lon /= c.pixels;
|
||||
out.push(c);
|
||||
}
|
||||
out.sort((a, b) => b.weight - a.weight);
|
||||
return out.slice(0, STRIKE_CAP);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind (GPU streamlines) ───────────────────────── */
|
||||
|
||||
class WindLayer extends Layer {
|
||||
constructor(ctx, def, { feed }) {
|
||||
super(ctx, { pollMs: 60 * 60_000, ...def });
|
||||
this.feed = feed;
|
||||
this.gpu = createWindGpuRendering({
|
||||
cesium: Cesium,
|
||||
getViewer: () => ctx.viewer,
|
||||
});
|
||||
this.field = null;
|
||||
this.started = performance.now();
|
||||
this._raf = 0;
|
||||
this._lastFrame = 0;
|
||||
const scene = ctx.scene;
|
||||
this._pre = scene.preRender.addEventListener(() => {
|
||||
if (!this.on || !this.field) return;
|
||||
this.gpu.tick((performance.now() - this.started) / 1000);
|
||||
this.gpu.updateVisibility(scene.camera);
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const d = this.feed.data;
|
||||
if (!d) return;
|
||||
if (d.field !== this.field) {
|
||||
this.field = d.field;
|
||||
if (this.on) this.gpu.setField(this.field);
|
||||
}
|
||||
this.count = this.gpu.getParticleCount();
|
||||
this.message = `${d.level} · ${d.source}`;
|
||||
this._loop();
|
||||
}
|
||||
|
||||
setOn(on) {
|
||||
super.setOn(on);
|
||||
if (on && this.field) {
|
||||
this.gpu.setField(this.field);
|
||||
this._loop();
|
||||
}
|
||||
}
|
||||
|
||||
onHide() {
|
||||
this.gpu.clear();
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._raf = 0;
|
||||
}
|
||||
|
||||
/** Flowing streaks need frames: ~30 fps while on and visible, 0 otherwise. */
|
||||
_loop() {
|
||||
if (this._raf || !this.on) return;
|
||||
const step = (t) => {
|
||||
this._raf = 0;
|
||||
if (!this.on) return;
|
||||
if (this.ctx.active() && t - this._lastFrame > 33) {
|
||||
this._lastFrame = t;
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
this._raf = requestAnimationFrame(step);
|
||||
};
|
||||
this._raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._pre?.();
|
||||
this.gpu.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
class AirQualityLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
super(ctx, {
|
||||
id: 'air-quality',
|
||||
family: 'Hazards',
|
||||
name: 'Air quality',
|
||||
pollMs: 60 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#F2D33B'),
|
||||
glyphName: 'aq',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
dotScale: 0.9,
|
||||
glyphScale: 0.5,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const recs = this.feed.data.records;
|
||||
this.records = new Map(recs.map((r) => [r.id, r]));
|
||||
this.count = recs.length;
|
||||
this.renderer.setItems(
|
||||
recs.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
color: color(aqiColor(r.aqi)),
|
||||
dotScale: 0.8 + Math.min(2, r.aqi / 100),
|
||||
glyphScale: 0.55 + Math.min(0.6, r.aqi / 300),
|
||||
})),
|
||||
);
|
||||
}
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
return r ? [r.lon, r.lat, 0] : null;
|
||||
}
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
return {
|
||||
kind: 'air-quality',
|
||||
id: r.id,
|
||||
title: `${r.name} · AQI ${Math.round(r.aqi)}`,
|
||||
subtitle: `PM2.5 ${r.pm25?.toFixed(0) ?? '–'} µg/m³ · modelled (CAMS)`,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
labelDetail: `AQI ${Math.round(r.aqi)}`,
|
||||
meta: { layer: this.id, aqi: r.aqi, pm25: r.pm25, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS interference ───────────────────────── */
|
||||
|
||||
const GNSS_COLORS = { high: '#FF3B6B', medium: '#FFB13D', low: '#4ADE80' };
|
||||
|
||||
class GnssLayer extends Layer {
|
||||
constructor(ctx) {
|
||||
super(ctx, {
|
||||
id: 'gnss',
|
||||
family: 'Infra',
|
||||
name: 'GNSS interference',
|
||||
pollMs: 2 * 60_000,
|
||||
});
|
||||
this.source = 'adsb.lol NIC/NACp (gpsjam.org bands)';
|
||||
this.cellsPrim = null;
|
||||
this.anchor = null;
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
_anchors() {
|
||||
const v = this.lastView;
|
||||
if (v && v.heightM < 3_000_000 && v.center)
|
||||
return [{ id: 'view', lat: v.center.lat, lon: v.center.lon }];
|
||||
return GNSS_ANCHORS;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const anchors = this._anchors();
|
||||
const results = await Promise.allSettled(
|
||||
anchors.map((a) =>
|
||||
gnssIntegrity.get(`${Math.round(a.lat)}:${Math.round(a.lon)}`, a),
|
||||
),
|
||||
);
|
||||
const ok = results
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
if (!ok.length) {
|
||||
this.status = 'error';
|
||||
this.message =
|
||||
results[0]?.reason?.message || 'GNSS integrity source unavailable';
|
||||
return;
|
||||
}
|
||||
this.status = 'live';
|
||||
this.message = 'Low NIC/NACp is a proxy for jamming or spoofing, not proof';
|
||||
const cells = new Map();
|
||||
for (const snap of ok)
|
||||
for (const c of snap.cells)
|
||||
cells.set(`${c.lat.toFixed(2)}:${c.lon.toFixed(2)}`, c);
|
||||
this.cells = cells;
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_draw() {
|
||||
if (this.cellsPrim) this.root.remove(this.cellsPrim);
|
||||
this.cellsPrim = null;
|
||||
const shown = [...this.cells.entries()].filter(
|
||||
([, c]) => c.level !== 'low' || c.total >= 6,
|
||||
);
|
||||
this.count = shown.filter(([, c]) => c.level !== 'low').length;
|
||||
if (!shown.length) return;
|
||||
const instances = shown.map(([key, c]) => {
|
||||
const h = 0.25;
|
||||
return new Cesium.GeometryInstance({
|
||||
id: { layer: this.id, key },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
c.lon - h,
|
||||
c.lat - h,
|
||||
c.lon + h,
|
||||
c.lat + h,
|
||||
),
|
||||
height: 2000,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(GNSS_COLORS[c.level], c.level === 'low' ? 0.18 : 0.5),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
this.cellsPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
const moved =
|
||||
!prev ||
|
||||
view.heightM < 3_000_000 !== prev.heightM < 3_000_000 ||
|
||||
(view.heightM < 3_000_000 &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 150);
|
||||
if (moved && this.on) this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'gnss',
|
||||
id: key,
|
||||
title: `GNSS ${c.level === 'high' ? 'interference likely' : c.level === 'medium' ? 'degraded' : 'nominal'}`,
|
||||
subtitle: `${c.bad}/${c.total} aircraft with low NIC/NACp · ${c.percent.toFixed(0)}%`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, ...c, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
function routeColor(routeId) {
|
||||
let h = 0;
|
||||
for (const ch of String(routeId)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return `hsl(${h % 360} 85% 62%)`;
|
||||
}
|
||||
|
||||
class TransitLayer extends Layer {
|
||||
constructor(ctx, { catalog }) {
|
||||
super(ctx, {
|
||||
id: 'transit',
|
||||
family: 'Ground',
|
||||
name: 'Transit vehicles',
|
||||
pollMs: 20_000,
|
||||
});
|
||||
this.catalog = catalog;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(FAMILY_COLORS.Ground),
|
||||
glyphName: 'bus',
|
||||
lodHeightM: 400_000,
|
||||
cap: 600,
|
||||
dotScale: 0.32,
|
||||
glyphScale: 0.38,
|
||||
});
|
||||
this.records = new Map();
|
||||
this.feedId = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let cat;
|
||||
try {
|
||||
cat = await this.catalog.get();
|
||||
} finally {
|
||||
this.fromFeed(this.catalog);
|
||||
}
|
||||
const c = this.lastView?.center;
|
||||
const feeds = cat.feeds;
|
||||
let best = null;
|
||||
for (const f of feeds) {
|
||||
const d = c ? haversineKm(c.lat, c.lon, f.center.lat, f.center.lon) : 0;
|
||||
if (!best || d - f.loadRadiusKm < best.d - best.f.loadRadiusKm)
|
||||
best = { f, d };
|
||||
}
|
||||
if (
|
||||
!best ||
|
||||
(c && best.d > best.f.loadRadiusKm + 400 && this.lastView?.heightM < 3e6)
|
||||
) {
|
||||
this.count = 0;
|
||||
this.message = 'No open transit feed near this view';
|
||||
this.renderer.clear();
|
||||
return;
|
||||
}
|
||||
// From orbit the nearest feed is arbitrary: show Helsinki's (busy, CC BY).
|
||||
const feed =
|
||||
this.lastView?.heightM >= 3e6
|
||||
? feeds.find((f) => f.id === 'hsl-helsinki') || best.f
|
||||
: best.f;
|
||||
this.feedId = feed.id;
|
||||
const snap = await transitVehicles.get(feed.id);
|
||||
this.source = `${feed.name} · ${feed.attribution}`;
|
||||
this.message = `${feed.region} · ${snap.vehicles.length} vehicles`;
|
||||
this.status = 'live';
|
||||
this.records = new Map(snap.vehicles.map((v) => [v.id, v]));
|
||||
this.count = snap.vehicles.length;
|
||||
this.renderer.setItems(
|
||||
snap.vehicles.map((v) => ({
|
||||
key: v.id,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
heading: v.bearing ?? 0,
|
||||
color: color(routeColor(v.routeId)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
this.renderer.setView(view);
|
||||
if (
|
||||
this.on &&
|
||||
prev?.center &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 120
|
||||
)
|
||||
this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const v = this.records.get(key);
|
||||
return v ? [v.lon, v.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const v = this.records.get(key);
|
||||
if (!v) return null;
|
||||
return {
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId || '?'}`,
|
||||
subtitle: [
|
||||
v.speedMps != null ? `${Math.round(v.speedMps * 3.6)} km/h` : null,
|
||||
v.status ? v.status.replace(/_/g, ' ').toLowerCase() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
meta: { layer: this.id, feedId: this.feedId, source: this.source },
|
||||
};
|
||||
}
|
||||
|
||||
contacts() {
|
||||
return [...this.records.values()].map((v) => ({
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId}`,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
altM: 0,
|
||||
headingDeg: v.bearing ?? 0,
|
||||
speedMps: v.speedMps,
|
||||
layer: this.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export { inRect };
|
||||
|
||||
export function createWeatherLayers(ctx, feeds) {
|
||||
return [
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{ id: 'radar', family: 'Weather', name: 'Radar' },
|
||||
{ feed: feeds.radar, product: 'radar', heightM: 4000, alpha: 0.9 },
|
||||
),
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'clouds',
|
||||
family: 'Weather',
|
||||
name: 'Clouds (satellite IR)',
|
||||
pollMs: 10 * 60_000,
|
||||
},
|
||||
{ feed: feeds.clouds, product: 'clouds', heightM: 7000, alpha: 0.85 },
|
||||
),
|
||||
new LightningLayer(ctx, { feed: feeds.lightning, width: 2048 }),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'wind', family: 'Weather', name: 'Wind (10 m)' },
|
||||
{ feed: feeds.wind },
|
||||
),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'jet', family: 'Weather', name: 'Jet stream (250 hPa)' },
|
||||
{ feed: feeds.jet },
|
||||
),
|
||||
new AirQualityLayer(ctx, { feed: feeds.airQuality }),
|
||||
new GnssLayer(ctx),
|
||||
new TransitLayer(ctx, { catalog: feeds.transitCatalog }),
|
||||
];
|
||||
}
|
||||
|
||||
export { WX };
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Pins for discourse and capture (DESIGN.md §11.3–11.4): posts, sealed
|
||||
* witness captures, public camera eyes, live broadcasts and holograms. Eyes
|
||||
* and live pins draw their view frustum as a translucent wedge on the ground
|
||||
* along the camera heading. Pins are few (tens), so the wedge primitive is
|
||||
* simply rebuilt on change.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { glyph } from './glyphs.js';
|
||||
import { offsetLonLat } from './geo.js';
|
||||
|
||||
export const PIN_KINDS = Object.freeze([
|
||||
'post',
|
||||
'witness',
|
||||
'eye',
|
||||
'live',
|
||||
'hologram',
|
||||
]);
|
||||
|
||||
const PIN_COLORS = Object.freeze({
|
||||
post: '#E8EEF7',
|
||||
witness: '#FFC857',
|
||||
eye: '#FFD166',
|
||||
live: '#FF4D6D',
|
||||
hologram: '#C9A7FF',
|
||||
});
|
||||
|
||||
const PIN_TITLES = Object.freeze({
|
||||
post: 'Post',
|
||||
witness: 'Sealed witness capture',
|
||||
eye: 'Public camera',
|
||||
live: 'Live broadcast',
|
||||
hologram: 'Hologram',
|
||||
});
|
||||
|
||||
export function createPins({ scene, requestRender }) {
|
||||
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
const billboards = root.add(new Cesium.BillboardCollection());
|
||||
const edges = root.add(new Cesium.PolylineCollection());
|
||||
let wedges = null;
|
||||
const pins = new Map();
|
||||
let pulse = 0;
|
||||
|
||||
function rebuildWedges() {
|
||||
if (wedges) root.remove(wedges);
|
||||
wedges = null;
|
||||
edges.removeAll();
|
||||
const instances = [];
|
||||
for (const p of pins.values()) {
|
||||
if (p.kind !== 'eye' && p.kind !== 'live') continue;
|
||||
if (!Number.isFinite(p.headingDeg)) continue;
|
||||
const half = (p.fovDeg ?? 60) / 2;
|
||||
const ring = [[p.lon, p.lat]];
|
||||
for (let a = -half; a <= half + 0.01; a += Math.max(2, half / 6))
|
||||
ring.push(offsetLonLat(p.lon, p.lat, p.headingDeg + a, p.rangeM));
|
||||
const css = p.color || PIN_COLORS[p.kind];
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: { layer: 'pins', key: p.id },
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height: (p.altM || 0) + 3,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(css).withAlpha(0.28),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
edges.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(
|
||||
[...ring, ring[0]].flatMap(([lo, la]) => [lo, la, (p.altM || 0) + 4]),
|
||||
),
|
||||
width: 1.5,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.fromCssColorString(css).withAlpha(0.85),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (instances.length)
|
||||
wedges = root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildBillboards() {
|
||||
billboards.removeAll();
|
||||
for (const p of pins.values()) {
|
||||
const g = glyph(p.kind);
|
||||
p._bb = billboards.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
p.lon,
|
||||
p.lat,
|
||||
(p.altM || 0) + 8,
|
||||
),
|
||||
image: g.image,
|
||||
imageId: g.imageId,
|
||||
color: Cesium.Color.fromCssColorString(p.color || PIN_COLORS[p.kind]),
|
||||
scale: 0.62,
|
||||
scaleByDistance: new Cesium.NearFarScalar(2e3, 1.15, 8e6, 0.55),
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: 2e5,
|
||||
id: { layer: 'pins', key: p.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
rebuildBillboards();
|
||||
rebuildWedges();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
return {
|
||||
add(pin) {
|
||||
if (!pin || pin.id == null) throw new TypeError('addPin needs an id');
|
||||
if (!Number.isFinite(pin.lon) || !Number.isFinite(pin.lat))
|
||||
throw new TypeError('addPin needs lon and lat');
|
||||
const kind = PIN_KINDS.includes(pin.kind) ? pin.kind : 'post';
|
||||
const p = {
|
||||
id: String(pin.id),
|
||||
lon: pin.lon,
|
||||
lat: pin.lat,
|
||||
kind,
|
||||
headingDeg: Number.isFinite(pin.headingDeg) ? pin.headingDeg : null,
|
||||
fovDeg: Number.isFinite(pin.fovDeg) ? pin.fovDeg : 60,
|
||||
rangeM: Number.isFinite(pin.rangeM) ? pin.rangeM : 350,
|
||||
color: pin.color || null,
|
||||
label: pin.label || '',
|
||||
altM: Number.isFinite(pin.altM) ? pin.altM : 0,
|
||||
};
|
||||
pins.set(p.id, p);
|
||||
changed();
|
||||
return p.id;
|
||||
},
|
||||
remove(id) {
|
||||
const ok = pins.delete(String(id));
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
list() {
|
||||
return [...pins.values()].map(({ _bb, ...p }) => ({ ...p }));
|
||||
},
|
||||
locate(id) {
|
||||
const p = pins.get(String(id));
|
||||
return p ? [p.lon, p.lat, p.altM || 0] : null;
|
||||
},
|
||||
pick(id) {
|
||||
const p = pins.get(String(id));
|
||||
if (!p) return null;
|
||||
return {
|
||||
kind: 'pin',
|
||||
pinKind: p.kind,
|
||||
id: p.id,
|
||||
title: p.label || PIN_TITLES[p.kind],
|
||||
subtitle: PIN_TITLES[p.kind],
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
meta: {
|
||||
layer: 'pins',
|
||||
headingDeg: p.headingDeg,
|
||||
fovDeg: p.fovDeg,
|
||||
label: p.label,
|
||||
},
|
||||
};
|
||||
},
|
||||
/** Live pins breathe once a second; returns true when anything changed. */
|
||||
tick() {
|
||||
let any = false;
|
||||
pulse = (pulse + 1) % 2;
|
||||
for (const p of pins.values())
|
||||
if (p.kind === 'live' && p._bb) {
|
||||
p._bb.scale = pulse ? 0.74 : 0.6;
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
},
|
||||
get billboards() {
|
||||
return billboards;
|
||||
},
|
||||
destroy() {
|
||||
scene.primitives.remove(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
+1821
-388
File diff suppressed because it is too large.
Load diff
+2072
-112
File diff suppressed because it is too large.
Load diff
@@ -572,3 +572,366 @@ input, textarea { font: inherit; color: inherit; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .12s !important; }
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
ROUND TWO — God's Eye identity: altitude bands, sensor framing, telemetry voice
|
||||
===================================================================================== */
|
||||
:root {
|
||||
--b-orbit: #B79CFF; --b-sky: #8CD4FF; --b-ground: #FFA53D; --b-inside: #F2CF7A; --harm: #FF3B30;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
--bg: #050608; --card: #0B0D11; --card2: #10131a;
|
||||
}
|
||||
.mono { font-family: var(--mono); letter-spacing: .02em; }
|
||||
.tab.on { color: #fff; }
|
||||
.tablens { background: rgba(255,255,255,.14); }
|
||||
.dot { background: var(--harm); }
|
||||
|
||||
/* ---------- hero: the orbit view with a reticle and burned-in telemetry ---------- */
|
||||
.now-hero { height: calc(var(--sat) + clamp(330px, 46vh, 440px)); }
|
||||
.now-hero .hud { position: absolute; inset: calc(var(--sat) + 8px) 12px auto 12px; height: calc(100% - 150px - var(--sat)); pointer-events: none; }
|
||||
.now-hero .hud .wm { position: absolute; left: 6px; top: 4px; font: 800 13px/1 var(--display); letter-spacing: .34em; color: #fff; text-shadow: 0 0 18px rgba(0,0,0,.8); }
|
||||
.hb { position: absolute; width: 22px; height: 22px; border: 0 solid rgba(255,255,255,.55); }
|
||||
.hb.a { left: 0; top: 26px; border-width: 1.5px 0 0 1.5px; } .hb.b { right: 0; top: 26px; border-width: 1.5px 1.5px 0 0; }
|
||||
.hb.c { left: 0; bottom: 0; border-width: 0 0 1.5px 1.5px; } .hb.d { right: 0; bottom: 0; border-width: 0 1.5px 1.5px 0; }
|
||||
.xh { position: absolute; left: 50%; top: 52%; width: 46px; height: 46px; margin: -23px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.35); }
|
||||
.xh::before, .xh::after { content: ""; position: absolute; background: rgba(255,255,255,.45); }
|
||||
.xh::before { left: -14px; right: -14px; top: 50%; height: 1px; } .xh::after { top: -14px; bottom: -14px; left: 50%; width: 1px; }
|
||||
.hud-t { position: absolute; left: 30px; right: 30px; bottom: 6px; font-size: 9.5px; color: rgba(220,210,255,.82); letter-spacing: .08em; text-align: center; text-shadow: 0 1px 6px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.now-hero .lt .kicker { font-size: 10.5px; letter-spacing: .12em; color: rgba(255,255,255,.7); }
|
||||
.now-hero .lt h1 { font: 800 30px/1.08 var(--display); letter-spacing: -.02em; margin-top: 6px; max-width: 13ch; }
|
||||
.counts { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 10px; font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--text3); }
|
||||
.counts b { color: var(--text); font-weight: 600; } .counts .w b { color: var(--b-inside); }
|
||||
.navbar .wm { font: 800 13px/1 var(--display); letter-spacing: .34em; }
|
||||
|
||||
/* ---------- altitude rail (band filter) ---------- */
|
||||
.bandrail { position: sticky; top: 0; z-index: 6; display: flex; align-items: stretch; gap: 0; padding: calc(var(--sat) + 8px) 10px 10px; margin-top: -2px; background: linear-gradient(180deg, rgba(5,6,8,.96) 70%, rgba(5,6,8,0)); }
|
||||
.bcell { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 8px 7px 7px; border-radius: 12px; position: relative; color: var(--text2); transition: background .2s, color .2s; }
|
||||
.bcell i { width: 100%; height: 2.5px; border-radius: 2px; background: var(--c); opacity: .45; margin-bottom: 5px; }
|
||||
.bcell b { font: 700 10.5px/1.1 var(--mono); letter-spacing: .1em; color: var(--text); }
|
||||
.bcell span { font: 600 15px/1.1 var(--display); color: var(--text); }
|
||||
.bcell em { font: 500 8.5px/1.2 var(--mono); font-style: normal; letter-spacing: .04em; color: var(--text3); text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.bcell.on { background: color-mix(in oklab, var(--c) 14%, rgba(255,255,255,.04)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--c) 55%, transparent); }
|
||||
.bcell.on i { opacity: 1; box-shadow: 0 0 10px var(--c); } .bcell.on b { color: var(--c); }
|
||||
.rarr { flex: none; width: 6px; align-self: center; height: 1px; background: rgba(255,255,255,.18); }
|
||||
.band-h { margin: 2px 16px 10px; padding: 10px 12px; border-radius: 12px; background: color-mix(in oklab, var(--c) 9%, transparent); box-shadow: inset 2px 0 0 var(--c); font-size: 12.5px; color: var(--text2); display: flex; gap: 10px; align-items: baseline; }
|
||||
.band-h b { color: var(--c); font: 700 12px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.feed-end { text-align: center; font-size: 9.5px; letter-spacing: .1em; color: rgba(246,223,160,.55); padding: 26px 20px 10px; display: flex; gap: 6px; justify-content: center; align-items: center; }
|
||||
#feed { display: flex; flex-direction: column; gap: 10px; padding-bottom: 6px; }
|
||||
#feed > .fc.wide { margin: 0 12px; }
|
||||
|
||||
/* ---------- feed cards: sensor frames ---------- */
|
||||
.fc { border-radius: 14px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.fc .fr { position: relative; width: 100%; overflow: hidden; background: #06080c; border-radius: 14px 14px 0 0; }
|
||||
.fc .fr canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.fc .fr::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0,0,0,.42), transparent 22%, transparent 74%, rgba(0,0,0,.7)); }
|
||||
.bk { position: absolute; z-index: 2; width: 14px; height: 14px; border: 0 solid color-mix(in oklab, var(--c) 85%, white); opacity: .9; pointer-events: none; }
|
||||
.bk.a { left: 7px; top: 7px; border-width: 1.5px 0 0 1.5px; } .bk.b { right: 7px; top: 7px; border-width: 1.5px 1.5px 0 0; }
|
||||
.bk.c { left: 7px; bottom: 7px; border-width: 0 0 1.5px 1.5px; } .bk.d { right: 7px; bottom: 7px; border-width: 0 1.5px 1.5px 0; }
|
||||
.ftop { position: absolute; z-index: 3; left: 14px; right: 14px; top: 11px; display: flex; gap: 6px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .12em; pointer-events: none; }
|
||||
.ftop .bt, .tags .bt, .rhead .bt { color: var(--c); padding: 3px 5px 2px; border-radius: 4px; background: rgba(0,0,0,.55); box-shadow: inset 0 0 0 .5px color-mix(in oklab, var(--c) 60%, transparent); font: 700 9px/1 var(--mono); letter-spacing: .12em; }
|
||||
.ftop .at { color: rgba(255,255,255,.75); }
|
||||
.ftop .lv, .ftop .mine { margin-left: auto; display: inline-flex; gap: 4px; align-items: center; color: #fff; background: rgba(255,59,48,.9); padding: 3px 5px 2px; border-radius: 4px; }
|
||||
.ftop .mine { background: var(--b-inside); color: #1b1408; }
|
||||
.ftop .lv i { width: 5px; height: 5px; border-radius: 50%; background: #fff; animation: blink 1.2s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.tele { position: absolute; z-index: 3; left: 14px; right: 14px; bottom: 11px; font: 600 8.5px/1.25 var(--mono); letter-spacing: .07em; color: rgba(255,255,255,.88); text-shadow: 0 1px 3px #000, 0 0 6px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||
.fc.wide .tele { font-size: 10px; }
|
||||
.readout { position: absolute; z-index: 3; left: 14px; bottom: 26px; font: 700 16px/1 var(--mono); letter-spacing: .02em; color: #fff; text-shadow: 0 1px 10px rgba(0,0,0,.9); }
|
||||
.a-track.b-sky .readout { color: #fff; } .fc[data-fid="squawk"] .readout { color: #FF6B61; }
|
||||
.fseal { position: absolute; z-index: 3; right: 12px; top: 30px; width: 24px; height: 24px; filter: drop-shadow(0 0 8px rgba(233,196,106,.7)); }
|
||||
.numov { position: absolute; z-index: 3; inset: auto 14px 26px 14px; }
|
||||
.a-number .fr canvas { filter: brightness(.62) saturate(1.1); }
|
||||
.numov .big { font: 800 40px/1 var(--display); letter-spacing: -.03em; color: #fff; text-shadow: 0 2px 20px rgba(0,0,0,.8); }
|
||||
.numov .big small { font: 600 12px var(--mono); letter-spacing: .06em; color: var(--c); margin-left: 5px; text-transform: uppercase; }
|
||||
.numov .nl { font-size: 12px; color: rgba(255,255,255,.82); margin-top: 3px; line-height: 1.25; }
|
||||
.numov .nsp { width: 100%; height: 30px; margin-top: 6px; overflow: visible; }
|
||||
.r4d { position: absolute; z-index: 3; left: 14px; top: 30px; display: flex; gap: 6px; }
|
||||
.fm { padding: 9px 11px 11px; }
|
||||
.fm .ttl { font-size: 14.5px; font-weight: 650; line-height: 1.26; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.fc.wide .fm .ttl { font-size: 17px; }
|
||||
.where { display: flex; flex-wrap: wrap; gap: 2px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text3); }
|
||||
.where .d { color: var(--text2); font-weight: 600; font-family: var(--mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||
.where span + span::before { content: "·"; margin-right: 8px; color: var(--text4); }
|
||||
.soc { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; margin-top: 8px; font-size: 11.5px; color: var(--text3); }
|
||||
.soc b { color: var(--text); font-weight: 600; }
|
||||
.soc .sl { display: inline-flex; align-items: center; gap: 4px; color: var(--b-inside); } .soc .sl b { color: var(--b-inside); }
|
||||
.soc .sl .ic { width: 13px; height: 13px; }
|
||||
.who { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; font-size: 12px; }
|
||||
.who b { font-weight: 650; }
|
||||
.bdg { display: inline-flex; align-items: center; gap: 3px; height: 18px; padding: 0 6px; border-radius: 5px; font: 650 9.5px/1 var(--font); letter-spacing: .02em; white-space: nowrap; }
|
||||
.bdg .ic { width: 11px; height: 11px; }
|
||||
.bdg.there { background: rgba(242,207,122,.16); color: var(--b-inside); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.5); }
|
||||
.bdg.sealed { background: rgba(242,207,122,.08); color: var(--gold2); }
|
||||
.bdg.local { background: rgba(255,165,61,.12); color: var(--b-ground); }
|
||||
.bdg.domain { background: rgba(140,212,255,.12); color: var(--b-sky); }
|
||||
.bdg.unv { background: repeating-linear-gradient(135deg, rgba(255,255,255,.06) 0 4px, transparent 4px 8px); color: var(--text3); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.2); }
|
||||
.fc .cta { margin-top: 9px; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 8px; background: color-mix(in oklab, var(--c) 16%, transparent); color: var(--c); font-size: 12.5px; font-weight: 700; box-shadow: inset 0 0 0 .5px color-mix(in oklab, var(--c) 50%, transparent); }
|
||||
.fc.mine { box-shadow: inset 0 0 0 1px rgba(242,207,122,.55), 0 0 30px rgba(242,207,122,.12); }
|
||||
.fc.flash { animation: flash 1.2s ease; }
|
||||
|
||||
/* room card */
|
||||
.fc.room { padding: 12px 13px 12px; background: linear-gradient(180deg, rgba(242,207,122,.06), rgba(255,255,255,.015) 40%), var(--card); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.22); }
|
||||
.rhead { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text2); --c: var(--b-inside); }
|
||||
.rtag { display: inline-flex; align-items: center; gap: 4px; font: 700 9px/1 var(--mono); letter-spacing: .12em; color: #1a1306; background: var(--b-inside); padding: 3px 5px 2px; border-radius: 4px; }
|
||||
.rtag .ic { width: 11px; height: 11px; stroke-width: 2.4; }
|
||||
.lvw { display: inline-flex; align-items: center; gap: 5px; margin-left: 2px; } .lvw b { color: var(--text); }
|
||||
.rhead .stack { margin-left: auto; }
|
||||
.fc.room h3 { margin: 9px 0 0; font: 750 19px/1.15 var(--display); letter-spacing: -.01em; }
|
||||
.claims { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.cl { display: grid; grid-template-columns: 74px 1fr; gap: 2px 8px; font-size: 13px; line-height: 1.35; color: rgba(240,244,250,.9); }
|
||||
.cl b { font: 700 9px/1.9 var(--mono); letter-spacing: .12em; color: var(--text3); }
|
||||
.cl.known b { color: var(--b-inside); } .cl.disputed b { color: #FFB25C; } .cl.open b { color: #fff; } .cl.unknown b { color: var(--text3); }
|
||||
.cl .cites { grid-column: 2; display: flex; gap: 4px; } .cl .cites i { font: 600 9px/1 var(--mono); font-style: normal; padding: 3px 4px; border-radius: 4px; background: rgba(255,255,255,.07); color: var(--text2); }
|
||||
.evs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 12px; }
|
||||
.evs .evc { width: 100%; aspect-ratio: 4/3; border-radius: 8px; display: block; background: #06080c; }
|
||||
.rfoot { display: flex; justify-content: space-between; align-items: center; margin-top: 11px; font-size: 11.5px; color: var(--text3); }
|
||||
.rfoot b { color: var(--text); font-weight: 600; } .rfoot .join { color: var(--b-inside); font-weight: 700; display: inline-flex; gap: 2px; align-items: center; }
|
||||
|
||||
/* ---------- sheet: room ---------- */
|
||||
.sh-hd .tags { display: flex; gap: 6px; align-items: center; } .sh-hd .tags .age { font-size: 12px; color: var(--text3); }
|
||||
.sh-hd .pl b { color: var(--text); font-weight: 600; }
|
||||
.presence-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px; color: var(--text2); }
|
||||
.presence-row b { color: var(--text); } .presence-row .rate { color: var(--text3); font-family: var(--mono); font-size: 10.5px; }
|
||||
.seg2 { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 12px 0 10px; padding: 3px; border-radius: 12px; background: rgba(255,255,255,.06); }
|
||||
.seg2 button { height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--text2); }
|
||||
.seg2 button.on { background: rgba(255,255,255,.14); color: #fff; box-shadow: inset 0 .5px 0 rgba(255,255,255,.3); }
|
||||
.seg2 button span { font-size: 11px; color: var(--text3); }
|
||||
.synth { border-radius: 14px; padding: 11px 12px 9px; background: linear-gradient(180deg, rgba(183,156,255,.09), rgba(183,156,255,.02)); box-shadow: inset 0 0 0 .5px rgba(183,156,255,.3); }
|
||||
.syh { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #CDBBFF; font-weight: 600; }
|
||||
.syh span { flex: 1; min-width: 0; }
|
||||
.regen { display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 700; background: rgba(183,156,255,.16); color: #E2D6FF; flex: none; }
|
||||
.regen.busy { opacity: .5; }
|
||||
.sy { display: grid; grid-template-columns: 74px 1fr; gap: 0 8px; margin-top: 8px; }
|
||||
.sy b { font: 700 9px/2.1 var(--mono); letter-spacing: .12em; grid-row: span 9; }
|
||||
.sy.kn b { color: var(--b-inside); } .sy.di b { color: #FFB25C; } .sy.un b { color: var(--text3); }
|
||||
.sy p { margin: 0 0 4px; font-size: 13px; line-height: 1.38; color: rgba(240,244,250,.9); grid-column: 2; }
|
||||
.sy p.none { color: var(--text3); } .sy p.stream { white-space: pre-wrap; font-family: var(--mono); font-size: 11.5px; }
|
||||
.syf { margin-top: 6px; font-size: 9.5px; color: var(--text3); letter-spacing: .03em; }
|
||||
.cite { display: inline-block; font: 650 9.5px/1 var(--mono); padding: 3px 4px 2px; border-radius: 4px; background: rgba(255,255,255,.08); color: var(--text2); vertical-align: 1px; margin-right: 2px; }
|
||||
.cite:active { background: rgba(255,255,255,.2); }
|
||||
.sortrow { display: flex; gap: 6px; align-items: center; margin: 14px 0 8px; } .sortrow .sl { font-size: 9.5px; letter-spacing: .12em; color: var(--text3); margin-right: 2px; }
|
||||
.chip.sm { height: 28px; padding: 0 11px; font-size: 12.5px; }
|
||||
.posts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.post { border-radius: 14px; padding: 10px 11px 9px; background: rgba(255,255,255,.035); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); transition: box-shadow .4s, background .4s; cursor: pointer; }
|
||||
.post.hl { background: rgba(242,207,122,.08); box-shadow: inset 0 0 0 1px rgba(242,207,122,.6); }
|
||||
.post.k-question { box-shadow: inset 2px 0 0 #fff, inset 0 0 0 .5px rgba(255,255,255,.07); }
|
||||
.post.noted { background: rgba(255,255,255,.02); }
|
||||
.post.noted .pt { color: var(--text3); text-decoration: line-through; text-decoration-color: rgba(255,255,255,.25); }
|
||||
.post.mine { box-shadow: inset 0 0 0 1px rgba(242,207,122,.45); }
|
||||
.ph { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.ph .avatar { margin-top: 1px; }
|
||||
.pw { flex: 1; min-width: 0; font-size: 12.5px; } .pw b { font-weight: 650; } .pw .pid { font: 600 9.5px var(--mono); color: var(--text3); margin-left: 6px; } .pw .tm { color: var(--text3); margin-left: 6px; font-size: 11.5px; }
|
||||
.bds { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
.kind { flex: none; display: inline-flex; align-items: center; gap: 4px; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 5px; background: rgba(255,255,255,.07); color: var(--text2); }
|
||||
.kind .ic { width: 11px; height: 11px; }
|
||||
.kind.k-witness { color: var(--b-inside); background: rgba(242,207,122,.12); } .kind.k-question { color: #fff; background: rgba(255,255,255,.14); }
|
||||
.kind.k-correction { color: #FFB25C; background: rgba(255,178,92,.12); } .kind.k-context { color: var(--b-sky); background: rgba(140,212,255,.1); } .kind.k-prediction { color: var(--b-orbit); background: rgba(183,156,255,.12); }
|
||||
.pt { margin: 7px 0 0; font-size: 14px; line-height: 1.42; color: rgba(242,245,250,.92); }
|
||||
.resolves { display: inline-block; margin-left: 6px; font: 600 9.5px var(--mono); color: var(--b-orbit); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.patt { position: relative; margin-top: 8px; border-radius: 10px; overflow: hidden; background: #06080c; aspect-ratio: 16/10; }
|
||||
.patt canvas { width: 100%; height: 100%; display: block; }
|
||||
.patt .tier { position: absolute; left: 8px; bottom: 8px; display: inline-flex; gap: 4px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 4px; background: rgba(0,0,0,.6); color: var(--b-inside); }
|
||||
.patt .tier .ic { width: 12px; height: 12px; }
|
||||
.patt.unverified .tier { color: var(--text2); } .patt.eye .tier { color: var(--b-ground); }
|
||||
.patt .plive { position: absolute; right: 8px; top: 8px; display: inline-flex; gap: 5px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; background: rgba(255,59,48,.9); padding: 4px 6px 3px; border-radius: 4px; }
|
||||
.patt .plive .dot { background: #fff; width: 5px; height: 5px; }
|
||||
.ancs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; align-items: center; }
|
||||
.anc { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: 7px; font-size: 11.5px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.anc .ic { width: 12px; height: 12px; color: var(--b-ground); }
|
||||
.note { margin-top: 9px; border-radius: 10px; padding: 9px 10px; background: rgba(140,212,255,.07); box-shadow: inset 0 0 0 .5px rgba(140,212,255,.3); }
|
||||
.note .nh { display: flex; gap: 5px; align-items: center; font-size: 11.5px; font-weight: 700; color: var(--b-sky); }
|
||||
.note p { margin: 5px 0 0; font-size: 13px; line-height: 1.4; color: rgba(240,244,250,.92); text-decoration: none; }
|
||||
.bridge { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.br { display: grid; grid-template-columns: 1fr 90px 34px; gap: 8px; align-items: center; font-size: 11px; color: var(--text3); }
|
||||
.br b { color: var(--text); font-weight: 600; text-align: right; }
|
||||
.br i { height: 5px; border-radius: 3px; background: linear-gradient(90deg, var(--b-sky) calc(var(--v) * 100%), rgba(255,255,255,.1) 0); }
|
||||
.note details { margin-top: 6px; font-size: 11.5px; color: var(--text2); } .note summary { cursor: pointer; color: var(--b-sky); font-weight: 600; }
|
||||
.note details p { font-size: 12px; color: var(--text2); }
|
||||
.answered { margin-top: 8px; display: flex; gap: 5px; align-items: center; font-size: 11.5px; color: var(--b-inside); }
|
||||
.answered.open { color: var(--text2); } .answered b { font-weight: 650; }
|
||||
.pf { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.rxb, .rep { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 7px; border-radius: 7px; font-size: 11.5px; color: var(--text2); background: rgba(255,255,255,.05); }
|
||||
.rcap { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 700; color: var(--b-inside); background: rgba(242,207,122,.12); }
|
||||
.room-srcs { margin-top: 18px; } .room-srcs .h3 { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text3); margin-bottom: 4px; }
|
||||
.room-srcs .r { grid-template-columns: 30px 1fr; }
|
||||
.fine { font-size: 11px; color: var(--text3); margin: 14px 0 90px; }
|
||||
/* composer & reactions */
|
||||
.roomcomp { position: absolute; z-index: 41; left: 10px; right: 10px; bottom: calc(var(--sab) + 10px); display: none; gap: 8px; align-items: flex-end; }
|
||||
.roomcomp.on { display: flex; animation: rise .4s var(--spring); }
|
||||
.capbtn { flex: none; width: 66px; height: 58px; border-radius: 18px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; background: var(--b-inside); color: #1b1408; font-size: 10.5px; font-weight: 800; box-shadow: 0 8px 30px rgba(242,207,122,.25); }
|
||||
.capbtn .ic { width: 22px; height: 22px; stroke-width: 2; }
|
||||
.rcin { flex: 1; min-width: 0; border-radius: 18px; padding: 6px; }
|
||||
.rcin .kinds { display: none; gap: 4px; overflow-x: auto; scrollbar-width: none; padding: 2px 2px 6px; }
|
||||
.roomcomp.typing .kinds { display: flex; }
|
||||
.rcin .kinds button { flex: none; display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 650; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.rcin .kinds button.on { background: #fff; color: #05070B; }
|
||||
.rcrow { display: flex; gap: 6px; align-items: center; }
|
||||
.rcrow input { flex: 1; min-width: 0; height: 44px; border: 0; outline: 0; background: transparent; padding: 0 10px; font-size: 15px; }
|
||||
.rcrow .send { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.14); }
|
||||
.app[data-mode="situation"] .sheet .sc { padding-bottom: 96px; }
|
||||
.rx { position: absolute; z-index: 40; right: 14px; bottom: calc(var(--sab) + 84px); width: 36px; height: 260px; pointer-events: none; display: none; }
|
||||
.app[data-mode="situation"] .rx { display: block; }
|
||||
.rx span { position: absolute; bottom: 0; left: 6px; animation: floatUp 2.6s ease-out forwards; color: var(--harm); filter: drop-shadow(0 2px 6px rgba(0,0,0,.6)); }
|
||||
.rx span:nth-child(3n) { color: var(--b-inside); } .rx span:nth-child(3n+1) { color: #fff; }
|
||||
.lbl.speech { padding: 4px 9px 4px 4px; font-size: 11.5px; border-radius: 12px 12px 12px 3px; }
|
||||
.lbl.speech .sg { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.12); color: #fff; }
|
||||
.lbl.speech .sg.k-witness { background: var(--b-inside); color: #1b1408; } .lbl.speech .sg.k-question { background: #fff; color: #05070B; }
|
||||
.lbl.speech .sg .ic { width: 12px; height: 12px; stroke-width: 2.2; }
|
||||
.lbl.speech.hl { box-shadow: inset 0 0 0 1.5px var(--b-inside); }
|
||||
@media (min-width: 600px) {
|
||||
.roomcomp { left: 10px; right: auto; width: calc(var(--paneW) - 20px); z-index: 36; }
|
||||
.rx { left: calc(var(--paneW) - 50px); right: auto; z-index: 36; }
|
||||
.bandrail { padding-top: 8px; }
|
||||
.now-hero .hud { display: none; }
|
||||
.now-hero .lt h1 { font-size: 26px; max-width: none; }
|
||||
}
|
||||
.where { display: block; margin-top: 6px; }
|
||||
.where .pl { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.where .d { font-size: 9.5px; letter-spacing: .06em; color: var(--text3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.where span + span::before { content: none; }
|
||||
.soc span { display: inline-flex; align-items: center; gap: 4px; } .soc .ic { width: 13px; height: 13px; }
|
||||
/* accessory: a person live, with a moving thumbnail */
|
||||
.accessory .accdot { width: 10px; display: grid; place-items: center; }
|
||||
.accessory .accv { width: 54px; height: 30px; border-radius: 6px; flex: none; background: #06080c; }
|
||||
/* concept tag sits above the sticky rail on Now */
|
||||
.app[data-tab="now"] .concept-tag { top: calc(var(--sat) + 8px); font-size: 8.5px; padding: 2px 6px; }
|
||||
.bandrail { padding-top: calc(var(--sat) + 28px); }
|
||||
.now-hero .hud { top: calc(var(--sat) + 30px); }
|
||||
.now-hero .hud .wm { top: -22px; }
|
||||
.hb.a, .hb.b { top: 0; }
|
||||
|
||||
/* ---------- capture: dial of four, live, upload, attach ---------- */
|
||||
.status .alt { font-size: 10.5px; letter-spacing: .06em; margin-left: 4px; }
|
||||
.dial button { padding: 0 2px; }
|
||||
.dial { gap: 18px; }
|
||||
.livecv { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #000; }
|
||||
.livecv.on { display: block; }
|
||||
.cap-ui.m-live .compass-tape, .cap-ui.m-live .callouts, .cap-ui.m-upload .callouts { display: none; }
|
||||
.cap-ui.m-upload::before { content: ""; position: absolute; inset: 0; background: rgba(3,4,7,.62); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); pointer-events: none; }
|
||||
.replyto { position: absolute; top: calc(var(--sat) + 60px); left: 12px; right: 12px; height: 38px; border-radius: 12px; display: flex; align-items: center; gap: 8px; padding: 0 6px 0 12px; font-size: 13px; color: var(--b-inside); }
|
||||
.replyto span { flex: 1; } .replyto b { color: #fff; } .replyto button { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.1); }
|
||||
.shutter.s-live i { background: #FF3B30; }
|
||||
.shutter.s-live.rec i { border-radius: 8px; transform: scale(.5); }
|
||||
.shutter.s-upload i { background: var(--b-inside); }
|
||||
#modeHost { position: absolute; inset: 0; pointer-events: none; }
|
||||
#modeHost > * { pointer-events: auto; }
|
||||
.lv-pre { position: absolute; left: 14px; right: 14px; bottom: calc(var(--sab) + 300px); border-radius: 18px; padding: 14px 15px; font-size: 13.5px; color: var(--text2); }
|
||||
.lv-pre b { color: #fff; font-size: 16px; } .lv-pre p { margin: 8px 0 10px; line-height: 1.45; }
|
||||
.lvph { display: flex; gap: 10px; align-items: center; }
|
||||
.lvnear { display: flex; flex-wrap: wrap; gap: 6px; } .lvnear span { display: inline-flex; gap: 5px; align-items: center; font-size: 12px; height: 26px; padding: 0 9px; border-radius: 8px; background: rgba(242,207,122,.12); color: var(--b-inside); }
|
||||
.lvfine { margin-top: 10px; font-size: 9px; letter-spacing: .1em; color: var(--text3); }
|
||||
.lv-top { position: absolute; top: calc(var(--sat) + 60px); left: 12px; right: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.lvpill { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; border-radius: 8px; background: #FF3B30; font: 800 12px/1 var(--mono); letter-spacing: .08em; color: #fff; }
|
||||
.lvpill i { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: blink 1.2s steps(2) infinite; }
|
||||
.lvpill.pre { background: rgba(255,255,255,.12); } .lvpill b { font-weight: 700; }
|
||||
.lvv { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 10px; border-radius: 8px; font-size: 13px; }
|
||||
.lvseal { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 9px; border-radius: 8px; background: rgba(0,0,0,.45); color: var(--b-inside); font: 700 10px/1 var(--mono); letter-spacing: .1em; }
|
||||
.lv-to { position: absolute; top: calc(var(--sat) + 100px); left: 12px; height: 30px; border-radius: 8px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; font-size: 12px; color: var(--text2); } .lv-to b { color: var(--b-inside); }
|
||||
.lv-chat { position: absolute; left: 12px; right: 70px; bottom: calc(var(--sab) + 330px); display: flex; flex-direction: column; gap: 6px; justify-content: flex-end; mask-image: linear-gradient(to top, #000 70%, transparent); -webkit-mask-image: linear-gradient(to top, #000 70%, transparent); pointer-events: none !important; }
|
||||
.lv-chat .bub .bx { padding: 6px 10px; font-size: 12.5px; } .lv-chat .lj { display: flex; gap: 6px; align-items: center; font-size: 11.5px; color: rgba(255,255,255,.75); text-shadow: 0 1px 4px #000; }
|
||||
.lvrx { position: absolute; right: 14px; bottom: calc(var(--sab) + 330px); width: 36px; height: 260px; pointer-events: none !important; }
|
||||
.lvrx span { position: absolute; bottom: 0; left: 6px; animation: floatUp 2.6s ease-out forwards; color: var(--harm); } .lvrx span:nth-child(2n) { color: #fff; }
|
||||
.lv-tele { position: absolute; left: 14px; right: 14px; bottom: calc(var(--sab) + 302px); font-size: 9.5px; letter-spacing: .06em; color: rgba(255,255,255,.88); text-shadow: 0 1px 3px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lv-chain { position: absolute; left: 0; right: 0; bottom: calc(var(--sab) + 214px); display: flex; align-items: center; gap: 0; overflow-x: auto; padding: 0 14px; scrollbar-width: none; }
|
||||
.lv-chain::-webkit-scrollbar { display: none; }
|
||||
.lv-chain .seg { flex: none; width: 86px; border-radius: 10px; padding: 5px; background: rgba(8,10,14,.78); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.45); display: flex; flex-direction: column; gap: 3px; }
|
||||
.lv-chain .seg img { width: 100%; height: 34px; object-fit: cover; border-radius: 6px; display: block; }
|
||||
.lv-chain .seg .si { font-size: 9px; color: var(--text3); } .lv-chain .seg .sh { font-size: 9px; color: var(--b-inside); }
|
||||
.lv-chain .seg .sk { display: inline-flex; gap: 3px; align-items: center; font-size: 9px; color: var(--text2); } .lv-chain .seg .sk .ic { width: 11px; height: 11px; }
|
||||
.lv-chain .seg.cur { box-shadow: inset 0 0 0 .5px rgba(255,255,255,.25); }
|
||||
.lv-chain .seg .fill { height: 34px; border-radius: 6px; background: rgba(255,255,255,.06); overflow: hidden; } .lv-chain .seg .fill i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, rgba(242,207,122,.15), rgba(242,207,122,.45)); }
|
||||
.lv-chain .ln { flex: none; width: 12px; height: 2px; background: linear-gradient(90deg, var(--b-inside), rgba(242,207,122,.3)); }
|
||||
.lv-chain .seg.flash { animation: flash 1.2s ease; }
|
||||
.up { position: absolute; left: 12px; right: 12px; top: calc(var(--sat) + 62px); max-height: calc(100% - var(--sat) - 62px - var(--sab) - 230px); overflow: auto; border-radius: 20px; padding: 14px; font-size: 13.5px; color: var(--text2); scrollbar-width: none; }
|
||||
.uph { display: flex; justify-content: space-between; align-items: center; gap: 10px; } .uph b { color: #fff; font-size: 16px; }
|
||||
.up p { margin: 8px 0 12px; line-height: 1.45; } .up p b { color: #fff; }
|
||||
.tierb { display: inline-flex; gap: 4px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 4px; background: rgba(0,0,0,.6); color: var(--b-inside); }
|
||||
.tierb.unv { color: var(--text2); background: repeating-linear-gradient(135deg, rgba(255,255,255,.08) 0 4px, rgba(0,0,0,.5) 4px 8px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.25); }
|
||||
.drop { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 22px 12px; border-radius: 16px; border: 1.5px dashed rgba(255,255,255,.22); color: var(--text2); cursor: pointer; margin-bottom: 10px; text-align: center; }
|
||||
.drop b { color: #fff; font-size: 15px; } .drop span { font-size: 12px; color: var(--text3); }
|
||||
.up .fine { margin: 10px 0 0; font-size: 9px; letter-spacing: .1em; text-align: center; }
|
||||
.upimg { position: relative; border-radius: 12px; overflow: hidden; } .upimg img { width: 100%; display: block; max-height: 220px; object-fit: cover; } .upimg .tierb { position: absolute; left: 8px; top: 8px; }
|
||||
.exif { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; margin: 10px 0; font-size: 10px; letter-spacing: .03em; }
|
||||
.exif div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .exif span { color: var(--text3); font-size: 8.5px; letter-spacing: .12em; } .exif b { color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.placer { position: relative; border-radius: 12px; overflow: hidden; touch-action: none; } .placer canvas { width: 100%; display: block; }
|
||||
.placer .pin { position: absolute; width: 34px; height: 34px; margin: -34px 0 0 -17px; display: grid; place-items: center; color: #1b1408; background: var(--b-inside); border-radius: 50% 50% 50% 4px; transform: rotate(-45deg); box-shadow: 0 6px 18px rgba(0,0,0,.6); cursor: grab; touch-action: none; }
|
||||
.placer .pin .ic { transform: rotate(45deg); }
|
||||
.placer .hint { position: absolute; left: 8px; bottom: 8px; font-size: 9.5px; letter-spacing: .06em; color: #fff; background: rgba(0,0,0,.6); padding: 4px 6px; border-radius: 4px; }
|
||||
.uptime { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; margin: 12px 0; font-size: 12px; } .uptime input { accent-color: var(--b-inside); } .uptime b { color: #fff; font-size: 11.5px; }
|
||||
.attach-ov { position: absolute; inset: 0; z-index: 78; display: none; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.5); }
|
||||
.attach-ov.on { display: flex; animation: viewIn .3s var(--out); }
|
||||
.att { width: 100%; max-width: 560px; max-height: 92%; overflow: auto; border-radius: 28px 28px 0 0; padding: 6px 16px calc(var(--sab) + 16px); scrollbar-width: none; }
|
||||
.att .grab { height: 18px; display: grid; place-items: center; } .att .grab::before { content: ""; width: 38px; height: 5px; border-radius: 3px; background: rgba(255,255,255,.3); }
|
||||
.ath { display: grid; grid-template-columns: 112px 1fr; gap: 12px; align-items: center; }
|
||||
.atimg { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 4/3; background: #06080c; } .atimg img { width: 100%; height: 100%; object-fit: cover; display: block; } .atimg .tierb { position: absolute; left: 6px; bottom: 6px; font-size: 7.5px; }
|
||||
.ath h2 { margin: 0; font: 750 21px/1.15 var(--display); } .ath p { margin: 4px 0 0; font-size: 10.5px; color: var(--text3); letter-spacing: .02em; }
|
||||
.ath2 { margin: 14px 0 7px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
|
||||
.sugs { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sug { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 14px; background: rgba(255,255,255,.05); text-align: left; box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.sug i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 10px var(--c); flex: none; }
|
||||
.sug span { flex: 1; min-width: 0; } .sug b { display: block; font-size: 14.5px; } .sug small { font-size: 12px; color: var(--text3); }
|
||||
.sug em { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.25); color: transparent; flex: none; }
|
||||
.sug.on { background: rgba(242,207,122,.1); box-shadow: inset 0 0 0 1px rgba(242,207,122,.55); } .sug.on em { background: var(--b-inside); color: #1b1408; box-shadow: none; }
|
||||
.kinds2, .prec { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.kinds2 button, .prec button { height: 32px; padding: 0 12px; border-radius: 10px; font-size: 13px; font-weight: 650; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.kinds2 button.on, .prec button.on { background: #fff; color: #05070B; }
|
||||
.prec { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.att textarea { width: 100%; margin-top: 10px; border: 0; outline: 0; resize: none; border-radius: 12px; padding: 10px 12px; background: rgba(255,255,255,.06); font-size: 14.5px; }
|
||||
.fine2 { font-size: 11.5px; color: var(--text3); margin: 7px 2px 0; line-height: 1.4; } .fine2.priv { display: flex; gap: 6px; align-items: flex-start; margin: 10px 2px 12px; }
|
||||
.att .row2.holo { margin-top: 10px; border-radius: 14px; background: rgba(255,255,255,.04); }
|
||||
.lbl.eyelbl { font-size: 11px; padding: 4px 9px 4px 4px; }
|
||||
.lbl.eyelbl .ei { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--b-inside); color: #1b1408; }
|
||||
.lbl.eyelbl .ei.k-cctv { background: var(--b-ground); } .lbl.eyelbl.k-you { box-shadow: inset 0 0 0 1.5px var(--b-inside), 0 0 24px rgba(242,207,122,.35); }
|
||||
.app[data-tab="capture"] .concept-tag { top: auto; bottom: calc(var(--sab) + 200px); left: 14px; right: auto; }
|
||||
.cap-top .st { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.cap-top .st:first-child { flex: 1 1 auto; } .cap-top .st:last-child { flex: 0 1 auto; }
|
||||
.lv-chat { right: 120px; } .lv-chat .bub { max-width: 100%; }
|
||||
.att { background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01)), rgba(13,16,22,.97); }
|
||||
|
||||
/* ---------- cold open: the descent ---------- */
|
||||
.cold { justify-content: center; padding: 0; background: radial-gradient(120% 80% at 50% 50%, transparent 40%, rgba(0,0,0,.55)); }
|
||||
.ladder { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); height: 300px; width: 120px; display: flex; flex-direction: column; justify-content: space-between; opacity: 0; transition: opacity .6s; }
|
||||
.cold.s0 .ladder { opacity: 1; }
|
||||
.ladder::before { content: ""; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 1px; background: linear-gradient(var(--b-orbit), var(--b-sky), var(--b-ground), var(--b-inside)); opacity: .5; }
|
||||
.lr { position: relative; padding-left: 18px; display: flex; flex-direction: column; gap: 2px; opacity: .38; transition: opacity .4s; }
|
||||
.lr i { position: absolute; left: 0; top: 3px; width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--c); }
|
||||
.lr b { font: 700 11px/1 var(--mono); letter-spacing: .16em; color: var(--c); } .lr span { font: 500 9px/1 var(--mono); letter-spacing: .06em; color: rgba(255,255,255,.6); }
|
||||
.lr.on { opacity: 1; } .lr.on i { background: var(--c); box-shadow: 0 0 12px var(--c); }
|
||||
.lmark { position: absolute; left: -3px; width: 15px; height: 2px; background: #fff; box-shadow: 0 0 10px #fff; top: 0; transition: top .15s linear; }
|
||||
.calt { position: absolute; left: 16px; top: calc(50% + 172px); font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #fff; text-shadow: 0 2px 16px #000; opacity: 0; transition: opacity .5s; }
|
||||
.cold.s0 .calt { opacity: 1; }
|
||||
.ccap { position: absolute; left: 16px; right: 16px; bottom: calc(var(--sab) + 40px); font-size: 11px; letter-spacing: .12em; color: rgba(255,255,255,.85); text-shadow: 0 1px 8px #000; }
|
||||
.cwit { position: absolute; left: 50%; top: 50%; width: min(78vw, 360px); aspect-ratio: 440/560; transform: translate(-50%, -50%) scale(1.25); opacity: 0; border-radius: 6px; overflow: hidden; transition: opacity .7s ease, transform 1.4s var(--out); --c: var(--b-inside); box-shadow: 0 30px 80px rgba(0,0,0,.6); }
|
||||
.cwit canvas { width: 100%; height: 100%; display: block; }
|
||||
.cwit .tele { position: absolute; left: 14px; right: 14px; bottom: 11px; font: 600 9.5px/1.2 var(--mono); letter-spacing: .08em; color: #fff; text-shadow: 0 1px 4px #000; }
|
||||
.cold.s3 .cwit { opacity: 1; transform: translate(-50%, -58%) scale(1); }
|
||||
.cold.s3 .ladder, .cold.s3 .calt { opacity: .0; }
|
||||
.cttl { position: absolute; left: 0; right: 0; bottom: calc(var(--sab) + 9vh); text-align: center; }
|
||||
.cttl .word { font: 800 34px/1 var(--display); letter-spacing: .3em; margin-left: .3em; opacity: 0; transition: opacity 1s ease, letter-spacing 1.8s var(--out); }
|
||||
.cttl .tag { font: 600 12px/1.4 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--b-inside); margin-top: 12px; opacity: 0; transition: opacity 1s ease .3s; }
|
||||
.cold.s4 .cttl .word { opacity: 1; letter-spacing: .2em; } .cold.s4 .cttl .tag { opacity: 1; }
|
||||
.cold.s3 .ccap { opacity: 0; }
|
||||
.rooms-why { margin: 8px 20px 4px; font-size: 13px; line-height: 1.45; color: var(--text2); }
|
||||
.rooms-why b { color: var(--text); font-weight: 600; }
|
||||
.rooms-live { display: flex; flex-direction: column; gap: 10px; padding: 0 12px; }
|
||||
@media (min-width: 600px) {
|
||||
.now-hero { height: auto; padding-top: calc(var(--sat) + 46px); }
|
||||
.now-hero .hud { display: block; position: absolute; top: calc(var(--sat) + 30px); left: 20px; right: 20px; height: 1px; }
|
||||
.now-hero .hud .hb, .now-hero .hud .xh, .now-hero .hud .hud-t { display: none; }
|
||||
.now-hero .hud .wm { left: 0; top: -14px; }
|
||||
.concept-tag, .app[data-tab] .concept-tag, .app[data-mode="situation"] .concept-tag { top: calc(var(--sat) + 62px); right: 66px; left: auto; bottom: auto; }
|
||||
.app[data-tab="now"] .concept-tag { top: calc(var(--sat) + 62px); }
|
||||
}
|
||||
/* LIVE is not harm: hazard red stays reserved for things that hurt people */
|
||||
.dot { background: #fff; }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,255,255,.55); } 70% { box-shadow: 0 0 0 7px rgba(255,255,255,0); } 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); } }
|
||||
.ftop .lv, .patt .plive { background: rgba(255,255,255,.94); color: #05070B; }
|
||||
.ftop .lv i, .patt .plive .dot { background: #05070B; }
|
||||
.lvpill { background: #fff; color: #05070B; } .lvpill i { background: #FF3B30; }
|
||||
.ribbon .nowbtn.live { background: rgba(255,255,255,.14); color: #fff; }
|
||||
.ribbon.breath::before { animation: none; }
|
||||
.badge.live .dot { background: #fff; }
|
||||
/* instrument readouts in the brief */
|
||||
.metric .v { font: 700 21px/1.1 var(--mono); letter-spacing: -.02em; }
|
||||
.metric .v small { font-family: var(--font); }
|
||||
.metric .l { font-size: 11.5px; } .metric .s { font-family: var(--mono); font-size: 9.5px; letter-spacing: .03em; }
|
||||
@@ -129,6 +129,8 @@
|
||||
<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="rx" id="rx" aria-hidden="true"></div>
|
||||
<div class="roomcomp" id="roomComp"></div>
|
||||
|
||||
<div class="ride" id="ride"></div>
|
||||
<section id="v-capture" class="view" aria-label="Capture"></section>
|
||||
@@ -142,7 +144,7 @@
|
||||
<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="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Rooms</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>
|
||||
@@ -151,14 +153,17 @@
|
||||
|
||||
<div class="morph" id="morph"></div>
|
||||
<div class="seal-ov" id="sealOv"></div>
|
||||
<div class="attach-ov" id="attachOv"></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 class="ladder" id="ladder"><div class="lr" data-b="orbit" style="--c:var(--b-orbit)"><i></i><b>ORBIT</b><span>36,000–400 KM</span></div><div class="lr" data-b="sky" style="--c:var(--b-sky)"><i></i><b>SKY</b><span>15 KM</span></div><div class="lr" data-b="ground" style="--c:var(--b-ground)"><i></i><b>GROUND</b><span>0 M</span></div><div class="lr" data-b="inside" style="--c:var(--b-inside)"><i></i><b>INSIDE</b><span>A WITNESS</span></div><div class="lmark" id="lmark"></div></div>
|
||||
<div class="calt mono" id="coldAlt">ALT 35,786 KM</div>
|
||||
<div class="ccap mono" id="coldCap"></div>
|
||||
<div class="cwit" id="coldWit"><canvas id="coldCv" width="440" height="560"></canvas><i class="bk a"></i><i class="bk b"></i><i class="bk c"></i><i class="bk d"></i><svg class="fseal"><use href="#i-seal"/></svg><div class="tele mono" id="coldTele">WITNESS · SEALED · ±4 M</div></div>
|
||||
<div class="cttl"><div class="word">GOD’S EYE</div><div class="tag">From orbit to the witness.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!--@TEX-->
|
||||
|
||||
@@ -279,6 +279,24 @@ const PEOPLE = {
|
||||
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 },
|
||||
// round two: people across the bands (all fictional)
|
||||
nadia: { h: 'nadia_basco', name: 'Nadia R.', hue: 18, home: [20.45, 121.97], where: 'Basco, Batanes' },
|
||||
rafa: { h: 'rafa_aparri', name: 'Rafael D.', hue: 140, home: [18.36, 121.64], where: 'Aparri' },
|
||||
mei: { h: 'mei_taitung', name: 'Mei-Ling C.', hue: 345, home: [22.76, 121.14], where: 'Taitung' },
|
||||
joon: { h: 'joon_pilotboat', name: 'Joon-ho P.', hue: 190, domain: 'Mariner', home: [22.61, 120.28], where: 'Kaohsiung' },
|
||||
lin: { h: 'lin_forecasts', name: 'Lin W.', hue: 225, domain: 'Met', where: 'Taipei' },
|
||||
repost: { h: 'breaking_now_24', name: 'breaking_now_24', hue: 0 },
|
||||
aiko: { h: 'aiko_sendai', name: 'Aiko S.', hue: 300, home: [38.27, 140.87], where: 'Sendai' },
|
||||
tetsu: { h: 'tetsu_rail', name: 'Tetsuya O.', hue: 60, domain: 'Rail ops', where: 'Fukushima' },
|
||||
tomas: { h: 'tomas_atc', name: 'Tomás V.', hue: 205, domain: 'Pilot', where: 'Reykjavík' },
|
||||
seat: { h: 'seat_23F', name: 'Passenger, 23F', hue: 40 },
|
||||
ines: { h: 'ines_coimbra', name: 'Inês M.', hue: 330, home: [40.2, -8.42], where: 'Coimbra' },
|
||||
bomb: { h: 'bombeiro_ruiz', name: 'Cpt. Ruiz', hue: 8, domain: 'First responder', home: [40.1, -8.0], where: 'Pampilhosa' },
|
||||
carla: { h: 'carla_avellaneda', name: 'Carla G.', hue: 280, home: [-34.66, -58.37], where: 'Avellaneda' },
|
||||
dev: { h: 'dev_storms', name: 'Devansh K.', hue: 110, domain: 'Storm spotter', where: 'Arlington, TX' },
|
||||
yuki: { h: 'yuki_kagoshima', name: 'Yuki T.', hue: 170, home: [31.6, 130.56], where: 'Kagoshima' },
|
||||
ola: { h: 'ola_tromso', name: 'Ola B.', hue: 150, home: [69.65, 18.96], where: 'Tromsø' },
|
||||
amara: { h: 'amara_lakefront', name: 'Amara J.', hue: 25, home: [41.88, -87.62], where: 'Chicago' },
|
||||
};
|
||||
|
||||
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 });
|
||||
@@ -320,6 +320,9 @@ W.updateDynamic = function () {
|
||||
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 - GE.T_CLIMB) / (2.23 * 3600000), 0, 1); const [la, lo] = geo.offset(40.7553, -73.9838, 16, 23.5); pushSpr(a, geo.vec(la, lo, (12 + f * 372) / 1000), [1, 0.3, 0.3, 1], 16, 9, -10, -1e9, 1e9, 0, 0.7, 0.9); }
|
||||
for (const sp of W.extraSprites || []) pushSpr(a, sp.v, sp.col, sp.size || 24, sp.glyph ?? 9, -10, -1e9, 1e9, 0, sp.seed || 0.3, 0.8);
|
||||
// eyes: people and cameras looking right now
|
||||
if (W.on('cctv') || W.on('signed') || W.on('streams')) for (const e of GE.EYES || []) pushSpr(a, geo.vec(e.lat, e.lon, 0.05), e.kind === 'you' ? [1, 0.9, 0.55, 1] : e.kind === 'cctv' ? [1, 0.66, 0.26, 1] : [0.96, 0.82, 0.48, 1], e.kind === 'you' ? 26 : 15, 9, -10, -1e9, 1e9, 0, e.lat * 0.1, 0.6);
|
||||
// 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
|
||||
@@ -336,6 +339,7 @@ W.updateDynamic = function () {
|
||||
}
|
||||
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);
|
||||
if (GE.eyeLines && (W.on('cctv') || W.on('signed') || W.on('streams'))) for (const ex of GE.eyeLines(W.lastRange)) pushPolyline(ln, ex.pts, ex.col, ex.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');
|
||||
};
|
||||
@@ -526,14 +530,14 @@ W.post = function (chain, outFb, outW, outH, mode, opts) {
|
||||
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.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : mode === 0 ? 0.58 : 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.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.12);
|
||||
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]);
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/* Painters — procedural sensor frames. Every feed card, room attachment and capture scene is drawn
|
||||
here as the instrument that saw it would show it: IR cloud tops with an enhancement table, radar
|
||||
reflectivity in dBZ bins over a street grid, CCTV frames with burned-in OSD, SAR speckle, shake maps,
|
||||
long-exposure launch arcs. Geography comes from the bundled NASA masks; weather and people are
|
||||
synthetic (Concept · illustrative data). */
|
||||
const PT = (GE.paint = {});
|
||||
const MONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace';
|
||||
PT.MONO = MONO;
|
||||
const hexrgb = (s) => [parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16)];
|
||||
const mk = (w, h) => { const c = document.createElement('canvas'); c.width = Math.max(1, w | 0); c.height = Math.max(1, h | 0); return c; };
|
||||
PT.mk = mk;
|
||||
|
||||
/* ---------- value noise + fbm ---------- */
|
||||
function noiseGen(seed) {
|
||||
const r = rng(seed * 7919 + 13); const p = new Uint8Array(512); const v = new Float32Array(256);
|
||||
for (let i = 0; i < 256; i++) { p[i] = i; v[i] = r(); }
|
||||
for (let i = 255; i > 0; i--) { const j = Math.floor(r() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
|
||||
for (let i = 0; i < 256; i++) p[i + 256] = p[i];
|
||||
const n = (x, y) => {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, X = xi & 255, Y = yi & 255;
|
||||
const u = xf * xf * (3 - 2 * xf), w = yf * yf * (3 - 2 * yf);
|
||||
const a = v[p[p[X] + Y]], b = v[p[p[X + 1] + Y]], c = v[p[p[X] + Y + 1]], d = v[p[p[X + 1] + Y + 1]];
|
||||
return a + (b - a) * u + (c - a) * w + (a - b - c + d) * u * w;
|
||||
};
|
||||
n.fbm = (x, y, o = 5, g = 0.5, l = 2.03) => { let s = 0, a = 0.5, f = 1, t = 0; for (let i = 0; i < o; i++) { s += a * n(x * f + i * 17.3, y * f - i * 9.1); t += a; a *= g; f *= l; } return s / t; };
|
||||
n.ridge = (x, y, o = 5) => { let s = 0, a = 0.5, f = 1, t = 0; for (let i = 0; i < o; i++) { const q = 1 - Math.abs(n(x * f + i * 5.2, y * f + i * 1.7) * 2 - 1); s += a * q * q; t += a; a *= 0.5; f *= 2.1; } return s / t; };
|
||||
return n;
|
||||
}
|
||||
PT.noise = noiseGen;
|
||||
|
||||
/* ---------- colour ramps → 256 LUT ---------- */
|
||||
function lut(stops) {
|
||||
const L = new Uint8ClampedArray(256 * 3); const S = stops.map(([t, c]) => [t, hexrgb(c)]);
|
||||
for (let i = 0; i < 256; i++) { const t = i / 255; let k = 1; while (k < S.length - 1 && S[k][0] < t) k++; const a = S[k - 1], b = S[k]; const f = clamp((t - a[0]) / (b[0] - a[0] || 1), 0, 1); for (let j = 0; j < 3; j++) L[i * 3 + j] = a[1][j] + (b[1][j] - a[1][j]) * f; }
|
||||
return L;
|
||||
}
|
||||
const LUT = {
|
||||
inferno: lut([[0, '#000004'], [0.15, '#1f0c48'], [0.3, '#550f6d'], [0.45, '#88226a'], [0.6, '#ba3655'], [0.72, '#e35933'], [0.84, '#f98e09'], [0.94, '#f6d746'], [1, '#fcffa4']]),
|
||||
mmi: lut([[0, '#bfccff'], [0.14, '#a0e6ff'], [0.28, '#80ffff'], [0.42, '#7aff93'], [0.56, '#ffff00'], [0.7, '#ffc800'], [0.84, '#ff6a00'], [1, '#c80000']]),
|
||||
};
|
||||
PT.LUT = LUT;
|
||||
// IR enhancement (BD-style): warm = dark grey; cold cloud tops step through cyan, blue, green, yellow, red, black, white
|
||||
function irColor(T, out) {
|
||||
if (T > -30) { const g = clamp(lerp(28, 196, (30 - T) / 60), 0, 255); out[0] = g; out[1] = g; out[2] = g + 4; return; }
|
||||
const st = [[-30, [150, 222, 245]], [-38, [52, 150, 225]], [-44, [28, 70, 200]], [-50, [40, 190, 70]], [-56, [235, 232, 70]], [-61, [246, 150, 40]], [-66, [226, 46, 30]], [-71, [120, 14, 18]], [-75, [12, 12, 14]], [-79, [96, 96, 100]], [-83, [236, 236, 240]], [-95, [255, 255, 255]]];
|
||||
for (let i = 1; i < st.length; i++) if (T >= st[i][0]) { const a = st[i - 1], b = st[i]; const f = (a[0] - T) / (a[0] - b[0]); for (let j = 0; j < 3; j++) out[j] = a[1][j] + (b[1][j] - a[1][j]) * f; return; }
|
||||
out[0] = out[1] = out[2] = 255;
|
||||
}
|
||||
const DBZ = [[18, '#03b33a'], [25, '#02d802'], [30, '#019a00'], [35, '#f4ec00'], [40, '#e4b800'], [45, '#fd8e00'], [50, '#fb0d00'], [55, '#cf0000'], [60, '#a80000'], [65, '#f400f6'], [70, '#9a52cc'], [75, '#ffffff']].map(([d, c]) => [d, hexrgb(c)]);
|
||||
function dbzColor(z, out) { if (z < 18) { out[3] = 0; return; } let k = 0; while (k < DBZ.length - 1 && DBZ[k + 1][0] <= z) k++; const c = DBZ[k][1]; out[0] = c[0]; out[1] = c[1]; out[2] = c[2]; out[3] = 225; }
|
||||
|
||||
/* ---------- raster helpers ---------- */
|
||||
// fn(u, v, out, x, y) fills out[0..3] for each cell of a w/k × h/k field; drawn scaled onto ctx
|
||||
function field(ctx, w, h, k, fn, smoothOn = true, comp) {
|
||||
const fw = Math.max(1, Math.round(w / k)), fh = Math.max(1, Math.round(h / k));
|
||||
const c = mk(fw, fh), cx = c.getContext('2d'); const id = cx.createImageData(fw, fh); const d = id.data; const out = [0, 0, 0, 255];
|
||||
for (let y = 0; y < fh; y++) for (let x = 0; x < fw; x++) { out[3] = 255; fn(x / fw, y / fh, out, x, y, fw, fh); const o = (y * fw + x) * 4; d[o] = out[0]; d[o + 1] = out[1]; d[o + 2] = out[2]; d[o + 3] = out[3]; }
|
||||
cx.putImageData(id, 0, 0);
|
||||
ctx.save(); if (comp) ctx.globalCompositeOperation = comp; ctx.imageSmoothingEnabled = smoothOn; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(c, 0, 0, w, h); ctx.restore();
|
||||
}
|
||||
PT.field = field;
|
||||
let GRAIN = null;
|
||||
function grain(ctx, w, h, a = 0.07) {
|
||||
if (!GRAIN) { GRAIN = mk(192, 192); const g = GRAIN.getContext('2d'); const id = g.createImageData(192, 192); const r = rng(99); for (let i = 0; i < id.data.length; i += 4) { const v = r() * 255; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; } g.putImageData(id, 0, 0); }
|
||||
ctx.save(); ctx.globalAlpha = a; ctx.globalCompositeOperation = 'overlay'; ctx.fillStyle = ctx.createPattern(GRAIN, 'repeat'); const ox = (Math.random() * 192) | 0, oy = (Math.random() * 192) | 0; ctx.translate(-ox, -oy); ctx.fillRect(0, 0, w + 192, h + 192); ctx.restore();
|
||||
}
|
||||
function vignette(ctx, w, h, a = 0.55, inner = 0.35) { const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * inner, w / 2, h / 2, Math.hypot(w, h) * 0.6); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, `rgba(0,0,0,${a})`); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); }
|
||||
function scan(ctx, w, h, a = 0.08, step = 3) { ctx.save(); ctx.fillStyle = `rgba(0,0,0,${a})`; for (let y = 0; y < h; y += step) ctx.fillRect(0, y, w, 1); ctx.restore(); }
|
||||
function glow(ctx, x, y, r, col, a = 1) { const g = ctx.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, col.replace('A', a)); g.addColorStop(0.35, col.replace('A', a * 0.35)); g.addColorStop(1, col.replace('A', 0)); ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2); }
|
||||
// OSD text, camera style
|
||||
function osd(ctx, txt, x, y, px, align = 'left', col = 'rgba(255,255,255,.92)', base = 'top') {
|
||||
ctx.save(); ctx.font = `600 ${px}px ${MONO}`; ctx.textAlign = align; ctx.textBaseline = base; ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = Math.max(2, px / 3.4); ctx.strokeStyle = 'rgba(0,0,0,.72)'; ctx.strokeText(txt, x, y); ctx.fillStyle = col; ctx.fillText(txt, x, y); ctx.restore();
|
||||
}
|
||||
function label(ctx, txt, x, y, px, col = 'rgba(235,240,248,.75)', align = 'left') { ctx.save(); ctx.font = `600 ${px}px ${MONO}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = col; ctx.shadowColor = 'rgba(0,0,0,.9)'; ctx.shadowBlur = 4; ctx.fillText(txt, x, y); ctx.restore(); }
|
||||
Object.assign(PT, { grain, vignette, scan, glow, osd, label, lut, irColor });
|
||||
const z2 = (n) => String(n).padStart(2, '0');
|
||||
PT.stamp = (ms, sep = ' ') => { const d = new Date(ms); return `${d.getUTCFullYear()}-${z2(d.getUTCMonth() + 1)}-${z2(d.getUTCDate())}${sep}${z2(d.getUTCHours())}:${z2(d.getUTCMinutes())}:${z2(d.getUTCSeconds())}`; };
|
||||
|
||||
/* ---------- maps (equirectangular, square km pixels) ---------- */
|
||||
function mapView(lat0, lon0, spanLon, w, h) {
|
||||
const spanLat = spanLon * Math.cos(lat0 * D2R) * (h / w);
|
||||
return { lat0, lon0, spanLon, spanLat, w, h, X: (lon) => ((((lon - lon0) + 540) % 360) - 180) / spanLon * w + w / 2, Y: (lat) => h / 2 - ((lat - lat0) / spanLat) * h, lon: (x) => lon0 + ((x - w / 2) / w) * spanLon, lat: (y) => lat0 + ((h / 2 - y) / h) * spanLat, kmPx: (spanLon * 111.32 * Math.cos(lat0 * D2R)) / w };
|
||||
}
|
||||
PT.mapView = mapView;
|
||||
const cropCache = new Map();
|
||||
function cropData(name, mv, w, h) {
|
||||
const key = [name, mv.lat0.toFixed(3), mv.lon0.toFixed(3), mv.spanLon.toFixed(3), w, h].join('|');
|
||||
if (cropCache.has(key)) return cropCache.get(key);
|
||||
const img = GE.world.img && GE.world.img[name]; const c = mk(w, h), cx = c.getContext('2d', { willReadFrequently: true });
|
||||
if (img) { cx.imageSmoothingQuality = 'high'; const iw = img.width, ih = img.height; const sx = ((mv.lon0 - mv.spanLon / 2 + 180) / 360) * iw, sy = ((90 - (mv.lat0 + mv.spanLat / 2)) / 180) * ih; cx.drawImage(img, sx, sy, (mv.spanLon / 360) * iw, (mv.spanLat / 180) * ih, 0, 0, w, h); }
|
||||
const d = cx.getImageData(0, 0, w, h).data; if (cropCache.size > 40) cropCache.clear(); cropCache.set(key, d); return d;
|
||||
}
|
||||
PT.cropData = cropData;
|
||||
// dark instrument map: land/sea from the water mask, optional relief (topo) and night lights
|
||||
function mapBase(ctx, mv, o = {}) {
|
||||
const { w, h } = mv; const k = o.k || 1; const fw = Math.round(w / k), fh = Math.round(h / k);
|
||||
const wm = cropData('water', mv, fw, fh); const tp = o.relief ? cropData('topo', mv, fw, fh) : null; const nl = o.lights ? cropData('night', mv, fw, fh) : null;
|
||||
const land = hexrgb(o.land || '#121922'), sea = hexrgb(o.sea || '#05080c'), coast = hexrgb(o.coast || '#46566c');
|
||||
field(ctx, w, h, k, (u, v, out, x, y) => {
|
||||
const i = (y * fw + x) * 4; const m = wm[i] / 255; const l = smooth(0.62, 0.38, m);
|
||||
let r = lerp(sea[0], land[0], l), g = lerp(sea[1], land[1], l), b = lerp(sea[2], land[2], l);
|
||||
if (tp && l > 0.3) { const e0 = tp[i], ex = tp[Math.min(i + 4, tp.length - 4)] - tp[Math.max(i - 4, 0)], ey = tp[Math.min(i + fw * 4, tp.length - 4)] - tp[Math.max(i - fw * 4, 0)]; const s = clamp(1 + (ey - ex) * 0.035 * (o.relief || 1) + e0 / 255 * 0.5, 0.55, 1.9); r *= s; g *= s; b *= s; }
|
||||
if (nl) { const L = Math.max(0, nl[i + 1] - 10) / 245 * o.lights; r += L * 255; g += L * 190; b += L * 110; }
|
||||
const e = smooth(0.6, 0.95, 1 - Math.abs(m - 0.5) * 2) * (o.coastA ?? 1); r = lerp(r, coast[0], e); g = lerp(g, coast[1], e); b = lerp(b, coast[2], e);
|
||||
out[0] = r; out[1] = g; out[2] = b;
|
||||
});
|
||||
}
|
||||
PT.mapBase = mapBase;
|
||||
function graticule(ctx, mv, step, col = 'rgba(160,190,230,.10)') {
|
||||
ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.setLineDash([2, 4]);
|
||||
for (let lo = Math.ceil((mv.lon0 - mv.spanLon / 2) / step) * step; lo < mv.lon0 + mv.spanLon / 2; lo += step) { const x = mv.X(lo); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, mv.h); ctx.stroke(); }
|
||||
for (let la = Math.ceil((mv.lat0 - mv.spanLat / 2) / step) * step; la < mv.lat0 + mv.spanLat / 2; la += step) { const y = mv.Y(la); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(mv.w, y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
function scaleBar(ctx, mv, km, x, y, col = 'rgba(235,240,248,.8)') { const px = km / mv.kmPx; ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x, y); ctx.lineTo(x + px, y); ctx.lineTo(x + px, y - 4); ctx.stroke(); ctx.restore(); label(ctx, km + ' KM', x + px + 5, y - 2, Math.max(8, mv.w / 42), col); }
|
||||
function plane(ctx, x, y, hdg, s, col) { ctx.save(); ctx.translate(x, y); ctx.rotate(hdg); ctx.scale(s / 24, s / 24); ctx.translate(-12, -12); ctx.fillStyle = col; ctx.fill(new Path2D('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')); ctx.restore(); }
|
||||
function cross(ctx, x, y, r, col, lw = 1.4) { ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.beginPath(); ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); ctx.stroke(); ctx.restore(); }
|
||||
function poly(ctx, pts, close) { ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))); if (close) ctx.closePath(); }
|
||||
Object.assign(PT, { graticule, scaleBar, plane, cross, poly });
|
||||
|
||||
/* =====================================================================================
|
||||
ORBIT
|
||||
===================================================================================== */
|
||||
// IR cloud tops of a tropical cyclone with eyewall lightning and the advisory track
|
||||
PT.ir = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 3);
|
||||
const C = GE.CYCLONE.at(0); const mv = mapView(C.lat + (o.dy ?? 0.6), C.lon + (o.dx ?? -0.8), o.span || 13, w, h);
|
||||
const k = 2, fw = Math.round(w / k), fh = Math.round(h / k); const wm = cropData('water', mv, fw, fh); const col = [0, 0, 0];
|
||||
const cl = Math.cos(C.lat * D2R);
|
||||
field(ctx, w, h, k, (u, v, out, x, y) => {
|
||||
const lat = mv.lat(v * h), lon = mv.lon(u * w); const dx = (lon - C.lon) * cl / 1.7, dy = (lat - C.lat) / 1.7; const r = Math.hypot(dx, dy), th = Math.atan2(dy, dx);
|
||||
const wn = N.fbm(dx * 0.8 + 11, dy * 0.8 + 3, 4);
|
||||
const arms = 0.5 + 0.5 * Math.sin(3 * (th - 2.1 * Math.log(r + 0.15)) + wn * 5.5);
|
||||
const conv = N.fbm(dx * 1.9 + 4, dy * 1.9 - 2, 5);
|
||||
const cdo = smooth(2.3, 0.9, r + (wn - 0.5) * 0.9), eye = smooth(0.16, 0.34, r), wall = Math.exp(-(((r - 0.4) / 0.15) ** 2));
|
||||
let c = Math.max(cdo * (0.8 + 0.22 * conv), arms * smooth(7.5, 1.8, r) * (0.3 + 0.7 * conv) * smooth(0.28, 0.62, conv));
|
||||
c = Math.max(c, wall * 0.97) * eye;
|
||||
const bg = smooth(0.64, 0.86, N.fbm(dx * 0.55 + 40, dy * 0.55 + 9, 5)) * 0.62; c = Math.max(c, bg * (1 - cdo) * smooth(1.5, 4, r));
|
||||
c += wall * smooth(0.6, 0.95, N(dx * 8, dy * 8)) * 0.12 + cdo * smooth(0.72, 0.98, N(dx * 5 + 3, dy * 5)) * 0.08;
|
||||
const i = (y * fw + x) * 4; const land = 1 - wm[i] / 255;
|
||||
irColor(26 - c * 114 + (c < 0.2 ? land * -10 : 0), col);
|
||||
const edge = smooth(0.62, 0.95, 1 - Math.abs(wm[i] / 255 - 0.5) * 2) * (c < 0.55 ? 0.9 : 0.35);
|
||||
out[0] = lerp(col[0], 236, edge); out[1] = lerp(col[1], 214, edge); out[2] = lerp(col[2], 96, edge);
|
||||
});
|
||||
graticule(ctx, mv, 5, 'rgba(255,255,255,.16)');
|
||||
// advisory track: past (solid dots), forecast (dashed + cone)
|
||||
const tr = GE.CYCLONE.track; ctx.save(); ctx.lineWidth = Math.max(1.2, w / 260);
|
||||
const P = (q) => [mv.X(q[2]), mv.Y(q[1])];
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.85)'; poly(ctx, tr.filter((q) => q[0] <= 0).map(P)); ctx.stroke();
|
||||
ctx.setLineDash([4, 4]); poly(ctx, tr.filter((q) => q[0] >= 0).map(P)); ctx.stroke(); ctx.setLineDash([]);
|
||||
const fut = tr.filter((q) => q[0] >= 0); const L = [], Rr = [];
|
||||
for (let i = 0; i < fut.length; i++) { const q = fut[i], n = fut[Math.min(i + 1, fut.length - 1)], p0 = fut[Math.max(i - 1, 0)]; const b = geo.bearing(p0[1], p0[2], n[1], n[2]); const km = GE.CYCLONE.cone(q[0]); const a = geo.dest(q[1], q[2], b - Math.PI / 2, km), c2 = geo.dest(q[1], q[2], b + Math.PI / 2, km); L.push([mv.X(a[1]), mv.Y(a[0])]); Rr.push([mv.X(c2[1]), mv.Y(c2[0])]); }
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1; poly(ctx, [...L, ...Rr.reverse()], true); ctx.stroke();
|
||||
for (const q of tr) { const [x, y] = P(q); ctx.beginPath(); ctx.arc(x, y, q[0] === 0 ? w / 70 : w / 150, 0, TAU); ctx.fillStyle = q[0] < 0 ? '#fff' : q[0] === 0 ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,.4)'; ctx.fill(); if (q[0] === 0) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); } }
|
||||
ctx.restore();
|
||||
// lightning in the eyewall and bands
|
||||
const R = rng(o.seed || 5); const cx0 = mv.X(C.lon), cy0 = mv.Y(C.lat); const pxDeg = w / mv.spanLon;
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < (o.flashes ?? 34); i++) { const rr = (i < 12 ? 0.35 + R() * 0.25 : 0.7 + R() * 3.2) * 1.7, a = R() * TAU; const x = cx0 + Math.cos(a) * rr * pxDeg, y = cy0 - Math.sin(a) * rr * pxDeg; glow(ctx, x, y, w / (i < 4 ? 9 : 26), 'rgba(200,230,255,A)', i < 4 ? 0.55 : 0.4); cross(ctx, x, y, w / 90, 'rgba(255,255,255,.95)', 1.2); }
|
||||
ctx.restore();
|
||||
label(ctx, 'IR 10.4 µm', w * 0.05, h * PT.topY, Math.max(9, w / 34), 'rgba(255,255,255,.85)');
|
||||
// enhancement legend
|
||||
const lx = w * 0.6, ly = h * PT.botY, lw = w * 0.34, lh = Math.max(4, h / 90);
|
||||
for (let i = 0; i < 60; i++) { irColor(10 - (i / 59) * 100, col); ctx.fillStyle = `rgb(${col[0]},${col[1]},${col[2]})`; ctx.fillRect(lx + (i / 60) * lw, ly, lw / 60 + 0.5, lh); }
|
||||
label(ctx, '+10', lx, ly - lh, Math.max(7, w / 48), 'rgba(255,255,255,.7)'); label(ctx, '−90 °C', lx + lw, ly - lh, Math.max(7, w / 48), 'rgba(255,255,255,.7)', 'right');
|
||||
};
|
||||
|
||||
// ISS external camera: the night side raycast from 418 km, airglow on the limb, truss in frame
|
||||
PT.isscam = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const lat0 = o.lat ?? 25.5, lon0 = o.lon ?? 31.0; const altKm = 418;
|
||||
const reg = mapView(lat0 + 14, lon0 + 2, 46, 900, 900 * 0.75);
|
||||
const RW = 900, RH = Math.round(900 * 0.75); const night = cropData('night', reg, RW, RH), clouds = cropData('clouds', reg, RW, RH), day = cropData('day', reg, RW, RH);
|
||||
const en = geo.enu(lat0, lon0); const eye = geo.vec(lat0, lon0, altKm); const hd = (o.hdg ?? 8) * D2R, pitch = (o.pitch ?? 24) * D2R;
|
||||
const fwdH = v3.add(v3.scale(en.north, Math.cos(hd)), v3.scale(en.east, Math.sin(hd)));
|
||||
const fwd = v3.norm(v3.add(v3.scale(fwdH, Math.cos(pitch)), v3.scale(en.up, -Math.sin(pitch))));
|
||||
const right = v3.norm(v3.cross(fwd, en.up)), up = v3.cross(right, fwd); const tanF = Math.tan((o.fov ?? 64) * D2R / 2), asp = w / h;
|
||||
const N = noiseGen(31); const R2 = rng(4);
|
||||
const samp = (D, lat, lon) => { const x = clamp(Math.round(((lon - (reg.lon0 - reg.spanLon / 2)) / reg.spanLon) * RW), 0, RW - 1), y = clamp(Math.round((((reg.lat0 + reg.spanLat / 2) - lat) / reg.spanLat) * RH), 0, RH - 1); return (y * RW + x) * 4; };
|
||||
field(ctx, w, h, 2, (u, v, out) => {
|
||||
const sx = (u * 2 - 1) * tanF * asp, sy = (1 - v * 2) * tanF; const d = v3.norm(v3.add(fwd, v3.add(v3.scale(right, sx), v3.scale(up, sy))));
|
||||
const b = v3.dot(eye, d), c = v3.dot(eye, eye) - 1, disc = b * b - c;
|
||||
if (disc > 0 && -b - Math.sqrt(disc) > 0) {
|
||||
const t = -b - Math.sqrt(disc); const p = v3.madd(eye, d, t); const [la, lo] = geo.ll(p); const i = samp(null, la, lo);
|
||||
const graz = clamp(-v3.dot(d, v3.norm(p)), 0, 1); const haze = Math.pow(1 - graz, 3);
|
||||
const L = Math.pow(night[i + 1] / 255, 1.25) * 1.5; const cl = clouds[i] / 255;
|
||||
out[0] = clamp(L * 255 * 1.05 + day[i] * 0.03 + cl * 14 + haze * 24, 0, 255); out[1] = clamp(L * 190 + day[i + 1] * 0.035 + cl * 18 + haze * 34, 0, 255); out[2] = clamp(L * 95 + day[i + 2] * 0.05 + cl * 26 + haze * 56, 0, 255);
|
||||
const lit = (1 - cl * 0.6); out[0] *= lit; out[1] *= lit; out[2] *= lit; out[0] += cl * 10; out[1] += cl * 12; out[2] += cl * 20;
|
||||
} else {
|
||||
const tc = Math.max(0, -b); const cp = v3.madd(eye, d, tc); const a = (v3.len(cp) - 1) * EARTH_KM;
|
||||
const glowG = Math.exp(-(((a - 92) / 9) ** 2)) * 0.95, glowR = Math.exp(-(((a - 110) / 14) ** 2)) * 0.35, sc = Math.exp(-Math.max(0, a) / 18) * 0.55;
|
||||
out[0] = clamp(glowR * 150 + sc * 40, 0, 255); out[1] = clamp(glowG * 150 + sc * 70, 0, 255); out[2] = clamp(glowG * 70 + sc * 120 + 6, 0, 255);
|
||||
if (a > 140 && R2() < 0.006) { const s = 120 + R2() * 135; out[0] = s; out[1] = s; out[2] = s + 10; }
|
||||
}
|
||||
});
|
||||
// a lightning flash under cloud near the horizon
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.28, h * 0.36, w * 0.12, 'rgba(170,200,255,A)', 0.7); glow(ctx, w * 0.31, h * 0.37, w * 0.05, 'rgba(240,248,255,A)', 0.8); ctx.restore();
|
||||
// truss & solar array in the corner, rim-lit
|
||||
ctx.save(); ctx.translate(w * 0.98, h * 0.02); ctx.rotate(0.42); ctx.fillStyle = '#07080b';
|
||||
ctx.fillRect(-w * 0.62, -h * 0.07, w * 0.9, h * 0.1); ctx.strokeStyle = 'rgba(214,170,90,.55)'; ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 16; i++) { ctx.beginPath(); ctx.moveTo(-w * 0.62 + i * w * 0.056, -h * 0.07); ctx.lineTo(-w * 0.62 + i * w * 0.056, h * 0.03); ctx.stroke(); }
|
||||
ctx.strokeStyle = 'rgba(255,214,140,.7)'; ctx.beginPath(); ctx.moveTo(-w * 0.62, h * 0.03); ctx.lineTo(w * 0.3, h * 0.03); ctx.stroke(); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45, 0.4); grain(ctx, w, h, 0.09);
|
||||
osd(ctx, `ISS EXT CAM 2 · ${altKm} KM`, w * 0.05, h * PT.topY, Math.max(9, w / 36));
|
||||
};
|
||||
|
||||
// your sky: the station's pass drawn as an arc on an alt-az chart
|
||||
PT.skychart = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const cx = w / 2, cy = h * 0.47, R = Math.min(w, h) * 0.4;
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h)); g.addColorStop(0, '#0d1430'); g.addColorStop(0.55, '#070a18'); g.addColorStop(1, '#020308'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
|
||||
const r = rng(o.seed || 12); ctx.save();
|
||||
for (let i = 0; i < 420; i++) { const a = r() * TAU, d = Math.sqrt(r()) * R * 1.35; const x = cx + Math.cos(a) * d, y = cy + Math.sin(a) * d; const m = Math.pow(r(), 6); ctx.fillStyle = `rgba(${220 + m * 35},${225 + m * 30},255,${0.25 + m * 0.75})`; ctx.fillRect(x, y, 1 + m * 2, 1 + m * 2); }
|
||||
// a dipper-shaped asterism
|
||||
const ast = [[-0.62, 0.3], [-0.48, 0.25], [-0.34, 0.29], [-0.22, 0.37], [-0.18, 0.51], [-0.02, 0.53], [0.02, 0.39]].map(([a, b]) => [cx + a * R, cy + b * R]);
|
||||
ctx.strokeStyle = 'rgba(160,190,255,.22)'; ctx.lineWidth = 1; poly(ctx, ast); ctx.stroke(); for (const [x, y] of ast) { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(x, y, w / 200 + 0.8, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = 'rgba(183,156,255,.28)'; ctx.setLineDash([2, 4]);
|
||||
for (const k of [1, 2 / 3, 1 / 3]) { ctx.beginPath(); ctx.arc(cx, cy, R * k, 0, TAU); ctx.stroke(); }
|
||||
ctx.setLineDash([]); ctx.strokeStyle = 'rgba(183,156,255,.6)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.stroke();
|
||||
const fs = Math.max(9, w / 30); for (const [t, a] of [['N', -90], ['E', 0], ['S', 90], ['W', 180]]) label(ctx, t, cx + Math.cos(a * D2R) * (R + fs * 0.9), cy + Math.sin(a * D2R) * (R + fs * 0.9), fs, t === 'N' ? '#FF6A3D' : 'rgba(220,210,255,.8)', 'center');
|
||||
label(ctx, '30°', cx + 3, cy - R * 2 / 3 + 7, fs * 0.72, 'rgba(183,156,255,.6)'); label(ctx, '60°', cx + 3, cy - R / 3 + 7, fs * 0.72, 'rgba(183,156,255,.6)');
|
||||
// the pass: rises WSW, culminates ~54°, sets ENE
|
||||
const az0 = 245 * D2R, az1 = 62 * D2R, maxEl = (o.max ?? 54) * D2R; const pts = [];
|
||||
for (let i = 0; i <= 60; i++) { const f = i / 60; let az = az0 + (((az1 - az0 + TAU * 1.5) % TAU) - Math.PI) * 0 + f * (((az1 - az0 + 3 * Math.PI) % TAU) - Math.PI); const el = Math.sin(f * Math.PI) * maxEl; const rr = R * (1 - el / (Math.PI / 2)); pts.push([cx + Math.sin(az) * rr, cy - Math.cos(az) * rr, f]); }
|
||||
ctx.lineWidth = Math.max(2, w / 120); const gr = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[60][0], pts[60][1]); gr.addColorStop(0, 'rgba(201,167,255,.25)'); gr.addColorStop(0.5, 'rgba(230,214,255,1)'); gr.addColorStop(1, 'rgba(201,167,255,.25)'); ctx.strokeStyle = gr; poly(ctx, pts); ctx.stroke();
|
||||
const p0 = o.at ?? Date.now() + 3.2 * 3600000;
|
||||
for (const k of [0, 20, 30, 40, 60]) { const [x, y] = pts[k]; ctx.fillStyle = '#E6D6FF'; ctx.beginPath(); ctx.arc(x, y, k === 30 ? w / 60 : w / 120, 0, TAU); ctx.fill(); if (k % 20 === 0 || k === 30) label(ctx, fmt.hm(p0 + (k / 60) * 6.5 * 60000), x + 7, y - 9, fs * 0.8, 'rgba(240,232,255,.9)'); }
|
||||
ctx.globalCompositeOperation = 'lighter'; glow(ctx, pts[30][0], pts[30][1], w / 10, 'rgba(200,170,255,A)', 0.55); ctx.restore();
|
||||
// moon
|
||||
ctx.save(); ctx.translate(cx + R * 0.62, cy + R * 0.45); ctx.fillStyle = '#E8E4D8'; ctx.beginPath(); ctx.arc(0, 0, w / 40, 0, TAU); ctx.fill(); ctx.fillStyle = '#070a18'; ctx.beginPath(); ctx.arc(w / 110, -w / 160, w / 42, 0, TAU); ctx.fill(); ctx.restore();
|
||||
label(ctx, `MAX ${Math.round(maxEl * R2D)}°`, pts[30][0], pts[30][1] + fs * 1.6, fs * 0.85, '#E6D6FF', 'center');
|
||||
};
|
||||
|
||||
// long-range tracking camera: launch arc + sunlit "jellyfish" plume in twilight
|
||||
PT.plume = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const r = rng(o.seed || 21); const N = noiseGen(8);
|
||||
const hz = h * 0.78; const sky = ctx.createLinearGradient(0, 0, 0, hz);
|
||||
sky.addColorStop(0, '#02040c'); sky.addColorStop(0.45, '#0a1638'); sky.addColorStop(0.78, '#1d3a6a'); sky.addColorStop(0.93, '#5a5e7a'); sky.addColorStop(1, '#c47b4a');
|
||||
ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
for (let i = 0; i < 260; i++) { const y = r() * hz * 0.7; ctx.fillStyle = `rgba(255,255,255,${0.15 + r() * 0.6 * (1 - y / hz)})`; ctx.fillRect(r() * w, y, 1, 1); }
|
||||
// arc: pad at lower left, pitching over to the right
|
||||
const pad = [w * 0.16, hz - 2]; const top = [w * 0.66, h * 0.22]; const ctrl = [w * 0.2, h * 0.18];
|
||||
const B = (t) => [(1 - t) ** 2 * pad[0] + 2 * (1 - t) * t * ctrl[0] + t * t * top[0], (1 - t) ** 2 * pad[1] + 2 * (1 - t) * t * ctrl[1] + t * t * top[1]];
|
||||
// sunlit plume (behind the arc): dome expanding up/right, iridescent rim
|
||||
const pc = B(0.86); ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < 90; i++) { const t = 0.55 + (i / 90) * 0.45; const p = B(t); const s = (t - 0.55) * w * 0.95; const a = (0.022 + 0.02 * N(i * 0.3, 2)) * (t > 0.7 ? 1 : (t - 0.55) / 0.15);
|
||||
const g = ctx.createRadialGradient(p[0] + s * 0.25, p[1] - s * 0.2, s * 0.05, p[0] + s * 0.25, p[1] - s * 0.2, s * 0.85); g.addColorStop(0, `rgba(210,235,255,${a * 1.2})`); g.addColorStop(0.55, `rgba(120,190,255,${a * 0.7})`); g.addColorStop(0.85, `rgba(255,160,120,${a * 0.35})`); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); }
|
||||
// jellyfish tendrils
|
||||
ctx.strokeStyle = 'rgba(190,225,255,.22)'; ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 26; i++) { const a = -0.3 + (i / 25) * 2.2; ctx.beginPath(); ctx.moveTo(pc[0], pc[1]); for (let k = 1; k <= 12; k++) { const d = k * w * 0.028; ctx.lineTo(pc[0] + Math.cos(a) * d + N(i, k * 0.4) * 10, pc[1] + Math.sin(a) * d * 0.6 + k * k * 0.5); } ctx.stroke(); }
|
||||
ctx.restore();
|
||||
// the long-exposure streak
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round';
|
||||
for (const [lw, col] of [[w / 22, 'rgba(255,120,40,.10)'], [w / 50, 'rgba(255,150,60,.35)'], [w / 120, 'rgba(255,220,170,.95)'], [w / 300, 'rgba(255,255,255,1)']]) {
|
||||
ctx.lineWidth = lw; ctx.strokeStyle = col; ctx.beginPath(); for (let i = 0; i <= 80; i++) { const t = i / 80; const p = B(t * 0.88); i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
|
||||
glow(ctx, pad[0], pad[1], w * 0.2, 'rgba(255,150,70,A)', 0.55); ctx.restore();
|
||||
// coast: land silhouette with lights, water reflecting the streak
|
||||
ctx.fillStyle = '#040508'; ctx.beginPath(); ctx.moveTo(0, hz); for (let x = 0; x <= w; x += 6) ctx.lineTo(x, hz - 3 - N.fbm(x / 60, 1, 3) * 12 * (x < w * 0.5 ? 1 : 0.4)); ctx.lineTo(w, hz + 8); ctx.lineTo(0, hz + 8); ctx.fill();
|
||||
for (let i = 0; i < 70; i++) { const x = r() * w; ctx.fillStyle = `rgba(255,${190 + r() * 50},${120 + r() * 60},${0.5 + r() * 0.5})`; ctx.fillRect(x, hz - 2 - r() * 6, 1.4, 1.4); }
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#0d1424'); wg.addColorStop(1, '#030407'); ctx.fillStyle = wg; ctx.fillRect(0, hz + 6, w, h - hz);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let y = hz + 8; y < h; y += 2) { const f = (y - hz) / (h - hz); const jit = (N(y * 0.2, 3) - 0.5) * 18 * f; ctx.fillStyle = `rgba(255,140,60,${0.35 * (1 - f)})`; ctx.fillRect(pad[0] - 4 - f * 10 + jit, y, 8 + f * 22, 1.4); ctx.fillStyle = `rgba(150,190,255,${0.08 * (1 - f)})`; ctx.fillRect(w * 0.5 + jit * 2, y, w * 0.3, 1); } ctx.restore();
|
||||
vignette(ctx, w, h, 0.4); grain(ctx, w, h, 0.1);
|
||||
osd(ctx, 'TRK CAM 3 · 400 MM · LONG EXPOSURE', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.8)');
|
||||
};
|
||||
|
||||
// active fire seen from orbit: hillshaded terrain, burn scar, front, hotspot pixels, smoke plume
|
||||
PT.firesat = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 17); const s = 2.1 / w;
|
||||
const scarC = [w * 0.62, h * 0.6], scarR = w * 0.26; const front = [];
|
||||
const H = (x, y) => N.ridge(x * s, y * s, 5) * 0.9 + N.fbm(x * s * 0.4 + 5, y * s * 0.4, 3) * 0.6;
|
||||
const scarAt = (x, y) => { const dx = x - scarC[0], dy = (y - scarC[1]) * 1.35; const a = Math.atan2(dy, dx); const rr = scarR * (0.75 + 0.45 * N(Math.cos(a) * 1.4 + 3, Math.sin(a) * 1.4 + 7)); return Math.hypot(dx, dy) / rr; };
|
||||
field(ctx, w, h, 2, (u, v, out) => {
|
||||
const x = u * w, y = v * h; const e = 2.2; const hc = H(x, y), hx = H(x + e, y), hy = H(x, y + e); const shade = clamp(0.82 + (hc - hx) * 15 - (hc - hy) * 11, 0.45, 1.35);
|
||||
const veg = smooth(0.42, 0.7, N.fbm(x * s * 1.8 + 30, y * s * 1.8, 4));
|
||||
let r = lerp(150, 62, veg), g = lerp(132, 82, veg), b = lerp(88, 48, veg);
|
||||
const fld = N(x * s * 6 + 70, y * s * 6) > 0.72 && hc < 0.55 ? 1 : 0; if (fld) { r = 168; g = 150; b = 98; }
|
||||
r *= shade; g *= shade; b *= shade;
|
||||
const sc = scarAt(x, y); const burnt = smooth(1.02, 0.94, sc); const rim = Math.exp(-(((sc - 1) / 0.05) ** 2));
|
||||
r = lerp(r, 34 + hc * 18, burnt); g = lerp(g, 30 + hc * 14, burnt); b = lerp(b, 28 + hc * 12, burnt);
|
||||
r = lerp(r, 92, rim * 0.6); g = lerp(g, 60, rim * 0.6); b = lerp(b, 40, rim * 0.6);
|
||||
out[0] = r; out[1] = g; out[2] = b;
|
||||
});
|
||||
// river & roads & a village
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(40,70,90,.9)'; ctx.lineWidth = w / 120; ctx.beginPath(); for (let i = 0; i <= 40; i++) { const t = i / 40; const x = t * w, y = h * 0.18 + Math.sin(t * 5) * h * 0.05 + N(t * 4, 9) * h * 0.08; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(225,215,190,.45)'; ctx.lineWidth = w / 260; for (const k of [0, 1, 2]) { ctx.beginPath(); for (let i = 0; i <= 30; i++) { const t = i / 30; const x = t * w, y = h * (0.35 + k * 0.28) + Math.sin(t * 3 + k) * h * 0.06 + N(t * 3, k * 5) * 20; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); }
|
||||
const vr = rng(3); for (let i = 0; i < 55; i++) { ctx.fillStyle = `rgba(${200 + vr() * 40},${190 + vr() * 30},${170 + vr() * 30},.8)`; ctx.fillRect(w * 0.2 + vr.gauss() * w * 0.04, h * 0.42 + vr.gauss() * h * 0.03, 2, 2); }
|
||||
ctx.restore();
|
||||
// fire front on the downwind (west) edge + hotspot pixels
|
||||
const R = rng(9); ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < 160; i++) { const a = Math.PI * (0.55 + R() * 0.95); let lo = 0.5, hi = 1.6; for (let k = 0; k < 14; k++) { const m = (lo + hi) / 2; const x = scarC[0] + Math.cos(a) * scarR * m, y = scarC[1] + (Math.sin(a) * scarR * m) / 1.35; if (scarAt(x, y) < 1) lo = m; else hi = m; } const x = scarC[0] + Math.cos(a) * scarR * lo, y = scarC[1] + (Math.sin(a) * scarR * lo) / 1.35; front.push([x, y]); glow(ctx, x, y, w / 36, 'rgba(255,90,20,A)', 0.4); }
|
||||
for (const [x, y] of front) { const q = R(); const ps = Math.max(2, w / 110); ctx.fillStyle = q > 0.8 ? 'rgba(255,240,150,.95)' : q > 0.4 ? 'rgba(255,140,30,.9)' : 'rgba(230,40,20,.85)'; ctx.fillRect(Math.round(x / ps) * ps, Math.round(y / ps) * ps, ps, ps); }
|
||||
ctx.restore();
|
||||
// smoke: drifts WNW from the front
|
||||
const src = [scarC[0] - scarR * 0.85, scarC[1] - scarR * 0.1], dir = v3.norm([-1, -0.55, 0]);
|
||||
field(ctx, w, h, 3, (u, v, out) => {
|
||||
const x = u * w - src[0], y = v * h - src[1]; const al = x * dir[0] + y * dir[1], ac = -x * dir[1] + y * dir[0]; if (al < -20) { out[3] = 0; return; }
|
||||
const wd = 16 + Math.max(0, al) * 0.5; const n = N.fbm(al / 46 + 4, ac / 22, 5); const dn = Math.exp(-((ac / wd) ** 2) * (1.4 - n)) * smooth(-20, 30, al) * Math.exp(-Math.max(0, al) / (w * 2.6)) * (0.5 + 0.8 * n);
|
||||
const a = clamp(dn * 1.1, 0, 0.92); out[0] = lerp(215, 160, n); out[1] = lerp(212, 160, n); out[2] = lerp(206, 170, n); out[3] = a * 255;
|
||||
}, true);
|
||||
label(ctx, 'VIIRS-CLASS 375 M · FRP 840 MW', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
const mv = { kmPx: 18 / w, w }; scaleBar(ctx, mv, 5, w * 0.06, h * PT.botY);
|
||||
// wind arrow
|
||||
ctx.save(); ctx.translate(w * 0.86, h * PT.botY - 6); ctx.rotate(Math.atan2(dir[1], dir[0])); ctx.strokeStyle = 'rgba(255,255,255,.85)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.lineTo(8, -5); ctx.moveTo(14, 0); ctx.lineTo(8, 5); ctx.stroke(); ctx.restore();
|
||||
};
|
||||
|
||||
// synthetic-aperture radar pass over an anchorage: speckle, bright hull returns with sidelobes
|
||||
PT.sar = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 23); const R = rng(o.seed || 23);
|
||||
const coast = (v) => w * (0.3 + 0.08 * Math.sin(v * 5) + 0.06 * N(v * 4, 2));
|
||||
field(ctx, w, h, 1.5, (u, v, out) => {
|
||||
const x = u * w, cxl = coast(v); const sea = smooth(cxl - 3, cxl + 3, x); const sp = -Math.log(Math.max(1e-4, R())) * 0.55;
|
||||
const fieldsTex = N(u * 26, v * 26) > 0.55 ? 0.22 : 0; const town = Math.exp(-(((u - 0.12) / 0.08) ** 2 + ((v - 0.62) / 0.12) ** 2)) * (N(u * 90, v * 90) > 0.5 ? 0.9 : 0.25);
|
||||
const land = (0.26 + N.fbm(u * 10, v * 10, 5) * 0.36 + fieldsTex + town) * (0.55 + sp * 0.6);
|
||||
const wave = (0.035 + N.fbm(u * 24 + 2, v * 6, 3) * 0.05) * (0.3 + sp * 1.3) + Math.exp(-(((x - cxl) / 7) ** 2)) * 0.25;
|
||||
const g = clamp(lerp(land, wave, sea), 0, 1) * 255; out[0] = g * 0.93; out[1] = g * 0.97; out[2] = g;
|
||||
}, false);
|
||||
// dredged channel + breakwater
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(0,0,0,.75)'; ctx.lineWidth = w / 60; ctx.beginPath(); ctx.moveTo(coast(0.05) + 4, 0); ctx.lineTo(w * 0.42, h * 0.45); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,.8)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(coast(0.5), h * 0.5); ctx.lineTo(w * 0.5, h * 0.47); ctx.stroke(); ctx.restore();
|
||||
// anchorage: hull returns with cross-shaped sidelobes
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; const n = o.ships || 48;
|
||||
for (let i = 0; i < n; i++) { const cl = i % 3; const x = w * ([0.62, 0.78, 0.56][cl] + R.gauss() * 0.08), y = h * ([0.3, 0.55, 0.78][cl] + R.gauss() * 0.07); const L = w / 30 * (0.6 + R() * 0.9); const a = 0.4 + R() * 0.5; ctx.save(); ctx.translate(x, y); ctx.rotate(a); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fillRect(-L / 2, -1.6, L, 3.2); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,.16)'; ctx.fillRect(x - L * 1.6, y - 0.5, L * 3.2, 1); ctx.fillRect(x - 0.5, y - L * 1.3, 1, L * 2.6); glow(ctx, x, y, L * 0.7, 'rgba(255,255,255,A)', 0.22); }
|
||||
// one moving ship with a wake and Doppler offset
|
||||
ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.fillRect(w * 0.44, h * 0.18, w / 22, 3); ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w * 0.44, h * 0.18 + 1); ctx.lineTo(w * 0.3, h * 0.12); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.fillRect(w * 0.47, h * 0.24, w / 22, 2);
|
||||
ctx.restore();
|
||||
label(ctx, 'SAR · C-BAND · VV · 10 M', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
scaleBar(ctx, { kmPx: 22 / w, w }, 5, w * 0.06, h * PT.botY);
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
SKY
|
||||
===================================================================================== */
|
||||
// radar reflectivity: a bowing squall line crossing a city's street grid, lightning, warning box
|
||||
PT.radar = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 41); const R = rng(o.seed || 41);
|
||||
ctx.fillStyle = '#0a0e14'; ctx.fillRect(0, 0, w, h);
|
||||
const cores = [[w * 0.62, h * 0.5, w * 0.34], [w * 0.22, h * 0.56, w * 0.22]];
|
||||
const urb = (x, y) => Math.max(...cores.map(([cx, cy, r]) => Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (r * r))));
|
||||
field(ctx, w, h, 2, (u, v, out) => { const x = u * w, y = v * h; const k = urb(x, y) * (0.6 + 0.5 * N.fbm(u * 9, v * 9, 3)); out[0] = 10 + k * 22; out[1] = 14 + k * 24; out[2] = 20 + k * 30; });
|
||||
// street grid
|
||||
ctx.save(); ctx.lineWidth = 1; const step = Math.max(5, w / 60);
|
||||
for (let x = 0; x < w; x += step) for (let y = 0; y < h; y += step) { const k = urb(x, y); if (R() > k * 1.3) continue; ctx.strokeStyle = `rgba(170,190,220,${0.06 + k * 0.1})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + step, y); ctx.moveTo(x, y); ctx.lineTo(x, y + step); ctx.stroke(); }
|
||||
// lakes, river, highways
|
||||
ctx.fillStyle = '#0f2236'; for (const [x, y, rx, ry] of [[0.42, 0.22, 0.05, 0.03], [0.86, 0.3, 0.04, 0.06], [0.1, 0.8, 0.05, 0.025]]) { ctx.beginPath(); ctx.ellipse(x * w, y * h, rx * w, ry * h, 0.4, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = 'rgba(70,120,170,.6)'; ctx.lineWidth = w / 200; ctx.beginPath(); for (let i = 0; i <= 50; i++) { const t = i / 50; const x = t * w, y = h * 0.62 + Math.sin(t * 9) * h * 0.03 + N(t * 5, 2) * h * 0.06; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(190,205,230,.32)'; ctx.lineWidth = w / 170;
|
||||
for (const [x1, y1, x2, y2] of [[0, 0.5, 1, 0.47], [0.62, 0, 0.6, 1], [0.22, 0, 0.25, 1], [0, 0.15, 1, 0.92], [0, 0.9, 1, 0.12]]) { ctx.beginPath(); ctx.moveTo(x1 * w, y1 * h); ctx.lineTo(x2 * w, y2 * h); ctx.stroke(); }
|
||||
ctx.beginPath(); ctx.ellipse(w * 0.62, h * 0.5, w * 0.16, h * 0.13, 0, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.ellipse(w * 0.22, h * 0.56, w * 0.1, h * 0.08, 0, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
// reflectivity: bowing line (arc), trailing stratiform, isolated cells ahead
|
||||
const ac = [w * 0.05, h * 0.5], AR = w * 0.58;
|
||||
const col = [0, 0, 0, 0];
|
||||
field(ctx, w, h, 3, (u, v, out) => {
|
||||
const x = u * w, y = v * h; const dx = x - ac[0], dy = y - ac[1]; const r = Math.hypot(dx, dy), a = Math.atan2(dy, dx);
|
||||
const span = smooth(1.25, 0.75, Math.abs(a)); const d = (r - AR - Math.cos(a * 2) * w * 0.05) / w;
|
||||
const n = N.fbm(x / 26, y / 26, 4), n2 = N(x / 9, y / 9);
|
||||
const core = Math.exp(-((d / 0.035) ** 2)) * span * (0.65 + 0.6 * n) * (1 + 0.5 * Math.exp(-((a / 0.22) ** 2)));
|
||||
const strat = Math.exp(-(((d + 0.12) / 0.11) ** 2)) * span * (0.5 + 0.7 * n);
|
||||
let cell = 0; for (const [cx, cy, cr] of [[0.82, 0.25, 0.04], [0.9, 0.62, 0.03], [0.76, 0.82, 0.035]]) cell = Math.max(cell, Math.exp(-((x - cx * w) ** 2 + (y - cy * h) ** 2) / (cr * w) ** 2) * (0.7 + 0.4 * n2));
|
||||
const z = 8 + Math.max(core * 58, strat * 28, cell * 52) + (n2 - 0.5) * 6; dbzColor(z, col); out[0] = col[0]; out[1] = col[1]; out[2] = col[2]; out[3] = col[3];
|
||||
}, false);
|
||||
// tornado-warning polygon at the bow apex (harm → hazard red)
|
||||
const ap = [ac[0] + AR + w * 0.05, ac[1]]; ctx.save(); ctx.strokeStyle = '#FF3B30'; ctx.lineWidth = Math.max(1.5, w / 160); poly(ctx, [[ap[0] - w * 0.06, ap[1] - h * 0.09], [ap[0] + w * 0.13, ap[1] - h * 0.06], [ap[0] + w * 0.12, ap[1] + h * 0.07], [ap[0] - w * 0.05, ap[1] + h * 0.08]], true); ctx.stroke(); ctx.restore();
|
||||
// lightning
|
||||
for (let i = 0; i < 40; i++) { const a = (R() - 0.5) * 1.6, rr = AR + (R() - 0.6) * w * 0.07; const x = ac[0] + Math.cos(a) * rr, y = ac[1] + Math.sin(a) * rr; cross(ctx, x, y, w / 110, i % 5 ? 'rgba(255,255,255,.9)' : 'rgba(255,230,0,1)', 1.2); }
|
||||
// storm motion
|
||||
ctx.save(); ctx.translate(ap[0] + w * 0.16, ap[1] + h * 0.17); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(-18, 0); ctx.lineTo(16, 0); ctx.lineTo(10, -5); ctx.moveTo(16, 0); ctx.lineTo(10, 5); ctx.stroke(); ctx.restore();
|
||||
label(ctx, '72 KM/H', ap[0] + w * 0.16, ap[1] + h * 0.21, Math.max(8, w / 44), 'rgba(255,255,255,.85)', 'center');
|
||||
for (const [t, x, y] of [['FORT WORTH', 0.22, 0.6], ['DALLAS', 0.62, 0.55], ['ARLINGTON', 0.43, 0.66]]) label(ctx, t, x * w, y * h, Math.max(7, w / 50), 'rgba(220,230,245,.55)', 'center');
|
||||
label(ctx, '0.5° REFLECTIVITY · dBZ', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
const lx = w * 0.05, ly = h * PT.botY; DBZ.forEach(([d, c], i) => { ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`; ctx.fillRect(lx + i * (w * 0.035), ly, w * 0.035, Math.max(4, h / 90)); }); label(ctx, '20', lx, ly - 6, Math.max(7, w / 52), 'rgba(255,255,255,.7)'); label(ctx, '75', lx + DBZ.length * w * 0.035, ly - 6, Math.max(7, w / 52), 'rgba(255,255,255,.7)', 'right');
|
||||
};
|
||||
|
||||
// one aircraft squawking 7700: the track turns red where it declared, diverts and descends
|
||||
PT.track = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const T = o.track;
|
||||
const lats = T.pts.map((p) => p[0]).concat(T.div.lat), lons = T.pts.map((p) => p[1]).concat(T.div.lon);
|
||||
const la0 = (Math.min(...lats) + Math.max(...lats)) / 2, lo0 = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
const span = Math.max(8, (Math.max(...lons) - Math.min(...lons)) * 1.5, ((Math.max(...lats) - Math.min(...lats)) * 1.6 * w) / h / Math.cos(la0 * D2R));
|
||||
const mv = mapView(la0, lo0, span, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.9, relief: 0.6, land: '#151c26', sea: '#04070b', coast: '#3a4a60' });
|
||||
graticule(ctx, mv, 5);
|
||||
const P = T.pts.map(([la, lo]) => [mv.X(lo), mv.Y(la)]); const k = T.sqAt;
|
||||
ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = Math.max(1.5, w / 200); ctx.strokeStyle = 'rgba(190,215,240,.75)'; poly(ctx, P.slice(0, k + 1)); ctx.stroke();
|
||||
ctx.lineWidth = Math.max(2.2, w / 130); ctx.strokeStyle = '#FF3B30'; ctx.shadowColor = 'rgba(255,59,48,.8)'; ctx.shadowBlur = 10; poly(ctx, P.slice(k)); ctx.stroke(); ctx.shadowBlur = 0;
|
||||
// remaining leg to the diversion field
|
||||
const cur = P[P.length - 1]; const dv = [mv.X(T.div.lon), mv.Y(T.div.lat)]; ctx.setLineDash([5, 5]); ctx.lineWidth = 1.4; ctx.strokeStyle = 'rgba(255,120,110,.8)'; ctx.beginPath(); ctx.moveTo(cur[0], cur[1]); ctx.quadraticCurveTo((cur[0] + dv[0]) / 2 + w * 0.05, (cur[1] + dv[1]) / 2, dv[0], dv[1]); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (const r of [w / 30, w / 18]) { ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(dv[0], dv[1], r, 0, TAU); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
label(ctx, T.div.iata, dv[0] + w / 16, dv[1], Math.max(9, w / 30), '#fff');
|
||||
const p0 = P[P.length - 2] || P[0]; plane(ctx, cur[0], cur[1], Math.atan2(cur[0] - p0[0], -(cur[1] - p0[1])), Math.max(14, w / 14), '#fff');
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, cur[0], cur[1], w / 6, 'rgba(255,59,48,A)', 0.55); ctx.restore();
|
||||
// squawk tag
|
||||
const tx = clamp(cur[0] + w * 0.04, 4, w - w * 0.36), ty = clamp(cur[1] - h * 0.12, 4, h * 0.7); ctx.fillStyle = '#FF3B30'; ctx.fillRect(tx, ty, w * 0.3, h * 0.065); label(ctx, 'SQ 7700', tx + w * 0.15, ty + h * 0.034, Math.max(9, w / 26), '#fff', 'center');
|
||||
// descent profile inset
|
||||
const ix = w * 0.05, iy = h * 0.6, iw = w * 0.42, ih = h * 0.14; ctx.fillStyle = 'rgba(5,8,12,.72)'; ctx.fillRect(ix, iy, iw, ih); ctx.strokeStyle = 'rgba(255,255,255,.15)'; ctx.strokeRect(ix + 0.5, iy + 0.5, iw - 1, ih - 1);
|
||||
const prof = T.alt; const mx = Math.max(...prof); ctx.beginPath(); prof.forEach((a, i) => { const x = ix + 4 + (i / (prof.length - 1)) * (iw - 8), y = iy + ih - 5 - (a / mx) * (ih - 14); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }); ctx.strokeStyle = '#FF6B61'; ctx.lineWidth = 1.6; ctx.stroke();
|
||||
label(ctx, 'ALT ↓ FL370 → FL' + Math.round(prof[prof.length - 1] / 100), ix + 5, iy + 8, Math.max(7, w / 50), 'rgba(255,255,255,.8)');
|
||||
};
|
||||
|
||||
// lightning density over a basin (number card backdrop)
|
||||
PT.strikes = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const mv = mapView(o.lat ?? 26.5, o.lon ?? -89, o.span ?? 22, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.7, land: '#10161e', sea: '#04070b', coast: '#2c3a4c' }); const R = rng(o.seed || 77);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
const cells = [[25.6, -88.2, 1.4], [27.4, -91.5, 1.0], [24.4, -84.9, 0.9], [28.6, -86.4, 0.7], [23.2, -92.8, 1.1]];
|
||||
for (const [la, lo, s] of cells) for (let i = 0; i < 260 * s; i++) { const x = mv.X(lo + R.gauss() * s), y = mv.Y(la + R.gauss() * s * 0.7); const age = R(); ctx.fillStyle = age < 0.15 ? 'rgba(255,255,255,.95)' : age < 0.5 ? 'rgba(255,230,90,.75)' : 'rgba(255,140,40,.45)'; ctx.fillRect(x, y, age < 0.15 ? 2.2 : 1.6, age < 0.15 ? 2.2 : 1.6); }
|
||||
for (const [la, lo] of cells) glow(ctx, mv.X(lo), mv.Y(la), w / 5, 'rgba(255,200,80,A)', 0.18);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// GNSS interference: hex bins of degraded integrity, tracks with gaps
|
||||
PT.gnss = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const mv = mapView(o.lat ?? 56.8, o.lon ?? 21.5, o.span ?? 17, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.8, land: '#121821', sea: '#05080c', coast: '#33425a' }); const R = rng(o.seed || 5);
|
||||
const hot = [[55.0, 20.5, 1.6], [59.3, 24.2, 1.2], [57.2, 19.0, 0.8]]; const hs = w / 16;
|
||||
ctx.save();
|
||||
for (let row = 0; row * hs * 0.87 < h + hs; row++) for (let col = 0; col * hs < w + hs; col++) {
|
||||
const x = col * hs + (row % 2 ? hs / 2 : 0), y = row * hs * 0.87; const la = mv.lat(y), lo = mv.lon(x);
|
||||
let k = 0; for (const [a, b, s] of hot) k = Math.max(k, Math.exp(-(((la - a) / s) ** 2 + ((lo - b) / (s * 1.8)) ** 2)));
|
||||
if (k < 0.12) continue; ctx.beginPath(); for (let i = 0; i < 6; i++) { const an = Math.PI / 6 + (i * Math.PI) / 3; ctx.lineTo(x + Math.cos(an) * hs * 0.5, y + Math.sin(an) * hs * 0.5); } ctx.closePath();
|
||||
ctx.fillStyle = `rgba(255,${Math.round(150 - k * 110)},${Math.round(60 - k * 40)},${0.12 + k * 0.5})`; ctx.fill(); ctx.strokeStyle = `rgba(255,140,90,${k * 0.5})`; ctx.stroke();
|
||||
}
|
||||
// airliner tracks: dashed where position integrity drops
|
||||
ctx.lineWidth = 1.2;
|
||||
for (let i = 0; i < 26; i++) { const y0 = R() * h, y1 = R() * h; let prevBad = null; for (let s = 0; s < 40; s++) { const t0 = s / 40, t1 = (s + 1) / 40; const x0 = t0 * w, x1 = t1 * w, ya = lerp(y0, y1, t0), yb = lerp(y0, y1, t1); const la = mv.lat(ya), lo = mv.lon(x0); let k = 0; for (const [a, b, sd] of hot) k = Math.max(k, Math.exp(-(((la - a) / sd) ** 2 + ((lo - b) / (sd * 1.8)) ** 2))); const bad = k > 0.4; ctx.strokeStyle = bad ? 'rgba(255,120,90,.0)' : 'rgba(150,205,255,.55)'; if (!bad) { ctx.beginPath(); ctx.moveTo(x0, ya); ctx.lineTo(x1, yb); ctx.stroke(); } if (bad && s % 2 === 0) { ctx.fillStyle = 'rgba(255,170,140,.7)'; ctx.fillRect(x0, ya + (R() - 0.5) * 6, 1.5, 1.5); } } }
|
||||
ctx.restore();
|
||||
label(ctx, 'NIC/NACp ≤ 4', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
// helicopter school: the circuit flown again and again over the field
|
||||
PT.heli = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(11);
|
||||
field(ctx, w, h, 2, (u, v, out) => { const n = N.fbm(u * 7, v * 7, 4), f = N(u * 22, v * 22); const g = f > 0.6 ? 1 : 0; out[0] = lerp(58, 92, n) + g * 18; out[1] = lerp(72, 98, n) + g * 22; out[2] = lerp(42, 60, n); });
|
||||
// tree lines & roads
|
||||
ctx.save(); ctx.fillStyle = 'rgba(25,40,22,.55)'; const R = rng(3); for (let i = 0; i < 40; i++) { ctx.beginPath(); ctx.ellipse(R() * w, R() * h, w * 0.06 * R(), h * 0.012, R() * 3, 0, TAU); ctx.fill(); } ctx.restore();
|
||||
ctx.save(); ctx.translate(w * 0.5, h * 0.5); ctx.rotate(0.06);
|
||||
ctx.fillStyle = '#6d6f70'; ctx.fillRect(-w * 0.36, -h * 0.025, w * 0.72, h * 0.05); ctx.save(); ctx.rotate(1.1); ctx.fillStyle = '#6a6c6d'; ctx.fillRect(-w * 0.3, -h * 0.02, w * 0.6, h * 0.04); ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.setLineDash([6, 6]); ctx.beginPath(); ctx.moveTo(-w * 0.34, 0); ctx.lineTo(w * 0.34, 0); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (let i = 0; i < 12; i++) { ctx.fillStyle = '#7d7f80'; ctx.fillRect(-w * 0.3 + i * w * 0.05, h * 0.08, w * 0.035, w * 0.035); }
|
||||
// circuit laps (oldest faint)
|
||||
const n = o.laps ?? 14; const cw = w * 0.62, ch = h * 0.42;
|
||||
for (let i = 0; i < n; i++) { const j = i / n; const off = (hash01(i + 1) - 0.5) * w * 0.03; ctx.strokeStyle = `rgba(155,240,150,${0.12 + j * 0.65})`; ctx.lineWidth = i === n - 1 ? 2.4 : 1.1; const r = w * 0.06; const x = -cw / 2 + off, y = -ch / 2 - h * 0.02 + off; ctx.beginPath(); ctx.roundRect(x, y, cw, ch, r); ctx.stroke(); }
|
||||
// helicopter
|
||||
const hx = cw / 2 - w * 0.02, hy = -ch * 0.1; ctx.fillStyle = '#E8FFE4'; ctx.beginPath(); ctx.arc(hx, hy, w / 60, 0, TAU); ctx.fill(); ctx.strokeStyle = 'rgba(232,255,228,.8)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(hx, hy, w / 22, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx, hy + w / 14); ctx.stroke();
|
||||
ctx.restore();
|
||||
const fs = Math.max(7, w / 44); for (const [t, x, y] of [['UPWIND', 0.5, 0.25], ['DOWNWIND', 0.5, 0.79], ['BASE', 0.12, 0.52], ['CROSSWIND', 0.88, 0.48]]) label(ctx, t, x * w, y * h, fs, 'rgba(225,255,220,.75)', 'center');
|
||||
vignette(ctx, w, h, 0.35);
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
GROUND
|
||||
===================================================================================== */
|
||||
// shake map: intensity bands over the coast, epicentre, stations
|
||||
PT.shake = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const Q = GE.BIGQ; const mv = mapView(Q.lat + 0.6, Q.lon - 1.6, o.span ?? 6.5, w, h);
|
||||
mapBase(ctx, mv, { relief: 1.1, land: '#161d26', sea: '#05080c', coast: '#5a6e88', lights: 0.6 });
|
||||
const fw = Math.round(w / 2); const wm = cropData('water', mv, fw, Math.round(h / 2)); const strike = 0.35;
|
||||
const mmi = (la, lo) => { const dx = (lo - Q.lon) * Math.cos(Q.lat * D2R), dy = la - Q.lat; const a = dx * Math.cos(strike) + dy * Math.sin(strike), b = -dx * Math.sin(strike) + dy * Math.cos(strike); const d = Math.hypot(a / 1.7, b) * 111; return clamp(8.2 - 2.1 * Math.log10(1 + d / 9) * 1.55, 1, 9); };
|
||||
field(ctx, w, h, 2, (u, v, out, x, y) => { const la = mv.lat(v * h), lo = mv.lon(u * w); const I = mmi(la, lo); const band = Math.floor(I); const t = clamp((band - 1) / 7, 0, 1); const j = Math.round(t * 255) * 3; const sea = wm[(y * fw + x) * 4] / 255 > 0.5; out[0] = LUT.mmi[j]; out[1] = LUT.mmi[j + 1]; out[2] = LUT.mmi[j + 2]; out[3] = I < 4 ? 0 : (sea ? 50 : 120) + (I - 4) * 26; }, true);
|
||||
// contour lines
|
||||
ctx.save(); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,.35)';
|
||||
for (const I of [4, 5, 6, 7]) { const d = 9 * (Math.pow(10, (8.2 - I) / (2.1 * 1.55)) - 1) / 111; ctx.beginPath(); for (let k = 0; k <= 72; k++) { const an = (k / 72) * TAU; const a = Math.cos(an) * d * 1.7, b = Math.sin(an) * d; const dx = a * Math.cos(strike) - b * Math.sin(strike), dy = a * Math.sin(strike) + b * Math.cos(strike); const x = mv.X(Q.lon + dx / Math.cos(Q.lat * D2R)), y = mv.Y(Q.lat + dy); k ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); }
|
||||
ctx.restore();
|
||||
// rupture
|
||||
ctx.save(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; const e = 0.45; ctx.beginPath(); ctx.moveTo(mv.X(Q.lon - Math.cos(strike) * e), mv.Y(Q.lat - Math.sin(strike) * e)); ctx.lineTo(mv.X(Q.lon + Math.cos(strike) * e), mv.Y(Q.lat + Math.sin(strike) * e)); ctx.stroke(); ctx.restore();
|
||||
// stations
|
||||
const R = rng(14); for (let i = 0; i < 46; i++) { const la = Q.lat + R.gauss() * 1.3 + 0.5, lo = Q.lon - 1.2 + R.gauss() * 1.2; const m = cropData('water', mv, fw, Math.round(h / 2)); const x = mv.X(lo), y = mv.Y(la); if (x < 0 || y < 0 || x >= w || y >= h) continue; if (m[(Math.floor(y / 2) * fw + Math.floor(x / 2)) * 4] > 128) continue; const I = mmi(la, lo) + R.gauss() * 0.4; const j = Math.round(clamp((I - 1) / 7, 0, 1) * 255) * 3; ctx.fillStyle = `rgb(${LUT.mmi[j]},${LUT.mmi[j + 1]},${LUT.mmi[j + 2]})`; ctx.strokeStyle = 'rgba(0,0,0,.7)'; const s = w / 70; ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x + s * 0.9, y + s * 0.6); ctx.lineTo(x - s * 0.9, y + s * 0.6); ctx.closePath(); ctx.fill(); ctx.stroke(); }
|
||||
// epicentre star
|
||||
const ex = mv.X(Q.lon), ey = mv.Y(Q.lat); ctx.save(); ctx.translate(ex, ey); ctx.fillStyle = '#FF3B30'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < 10; i++) { const r = i % 2 ? w / 50 : w / 22; const a = -Math.PI / 2 + (i * Math.PI) / 5; ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
|
||||
for (const [t, la, lo] of [['SENDAI', 38.27, 140.87], ['FUKUSHIMA', 37.75, 140.47], ['MITO', 36.37, 140.47], ['TOKYO', 35.68, 139.69]]) { const x = mv.X(lo), y = mv.Y(la); ctx.fillStyle = '#fff'; ctx.fillRect(x - 1.5, y - 1.5, 3, 3); label(ctx, t, x - 5, y, Math.max(7, w / 48), 'rgba(235,240,248,.85)', 'right'); }
|
||||
const lx = w * 0.05, ly = h * PT.botY; for (let i = 2; i <= 8; i++) { const j = Math.round(((i - 1) / 7) * 255) * 3; ctx.fillStyle = `rgb(${LUT.mmi[j]},${LUT.mmi[j + 1]},${LUT.mmi[j + 2]})`; ctx.fillRect(lx + (i - 2) * w * 0.05, ly, w * 0.05, Math.max(4, h / 90)); label(ctx, ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'][i], lx + (i - 2) * w * 0.05 + w * 0.025, ly - 7, Math.max(6, w / 60), 'rgba(255,255,255,.7)', 'center'); }
|
||||
label(ctx, 'MMI · INSTRUMENTAL INTENSITY', w * 0.05, h * PT.topY, Math.max(8, w / 42), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
// crowd density inside and around a stadium, top-down, with an egress crush point
|
||||
PT.stadium = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 66); const N = noiseGen(66);
|
||||
ctx.fillStyle = '#0b0e12'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.save(); ctx.fillStyle = '#12161c'; for (let i = 0; i < 70; i++) { const x = R() * w, y = R() * h; if (Math.hypot((x - w / 2) / w, (y - h / 2) / h) < 0.36) continue; ctx.fillRect(x, y, w * (0.04 + R() * 0.07), h * (0.03 + R() * 0.05)); } ctx.restore();
|
||||
const cx = w / 2, cy = h * 0.48, RX = w * 0.36, RY = h * 0.3;
|
||||
ctx.fillStyle = '#1b2028'; ctx.beginPath(); ctx.ellipse(cx, cy, RX * 1.08, RY * 1.08, 0, 0, TAU); ctx.fill();
|
||||
// intensity buffer of people
|
||||
const k = 2, fw = Math.round(w / k), fh = Math.round(h / k); const I = new Float32Array(fw * fh);
|
||||
const splat = (x, y, s, a) => { const r = Math.ceil(s * 2.5), x0 = Math.round(x / k), y0 = Math.round(y / k); for (let j = -r; j <= r; j++) for (let i = -r; i <= r; i++) { const X = x0 + i, Y = y0 + j; if (X < 0 || Y < 0 || X >= fw || Y >= fh) continue; I[Y * fw + X] += a * Math.exp(-(i * i + j * j) / (s * s)); } };
|
||||
for (let n = 0; n < 2600; n++) { const a = R() * TAU, t = 0.55 + R() * 0.45; splat(cx + Math.cos(a) * RX * t, cy + Math.sin(a) * RY * t, 1.6, 0.35 + 0.5 * N(Math.cos(a) * 3, Math.sin(a) * 3)); }
|
||||
const gates = [[-0.25, 'A'], [1.2, 'B'], [Math.PI * 0.62, 'C'], [Math.PI * 1.25, 'D']];
|
||||
for (const [ga, gl] of gates) { const gx = cx + Math.cos(ga) * RX * 1.08, gy = cy + Math.sin(ga) * RY * 1.08; const crush = gl === 'C'; for (let n = 0; n < (crush ? 900 : 260); n++) { const d = Math.pow(R(), crush ? 1.8 : 1) * w * (crush ? 0.2 : 0.3); const sp = R.gauss() * (crush ? 0.18 : 0.12); splat(gx + Math.cos(ga + sp) * d, gy + Math.sin(ga + sp) * d, 1.8, crush ? 0.6 : 0.3); } }
|
||||
const c2 = mk(fw, fh), cx2 = c2.getContext('2d'); const id = cx2.createImageData(fw, fh);
|
||||
for (let i = 0; i < fw * fh; i++) { const v = clamp(I[i] / 3.2, 0, 1); const j = Math.round(v * 255) * 3; id.data[i * 4] = LUT.inferno[j]; id.data[i * 4 + 1] = LUT.inferno[j + 1]; id.data[i * 4 + 2] = LUT.inferno[j + 2]; id.data[i * 4 + 3] = v < 0.04 ? 0 : clamp(v * 900, 0, 235); }
|
||||
cx2.putImageData(id, 0, 0); ctx.save(); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(c2, 0, 0, w, h); ctx.restore();
|
||||
// pitch
|
||||
ctx.save(); ctx.fillStyle = '#16301e'; ctx.fillRect(cx - RX * 0.42, cy - RY * 0.42, RX * 0.84, RY * 0.84); ctx.strokeStyle = 'rgba(220,255,220,.35)'; ctx.lineWidth = 1; ctx.strokeRect(cx - RX * 0.42, cy - RY * 0.42, RX * 0.84, RY * 0.84); ctx.beginPath(); ctx.moveTo(cx, cy - RY * 0.42); ctx.lineTo(cx, cy + RY * 0.42); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, RY * 0.12, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
for (const [ga, gl] of gates) { const gx = cx + Math.cos(ga) * RX * 1.16, gy = cy + Math.sin(ga) * RY * 1.16; label(ctx, 'GATE ' + gl, gx, gy, Math.max(7, w / 46), gl === 'C' ? '#fff' : 'rgba(230,236,245,.6)', 'center'); }
|
||||
const ga = gates[2][0]; const hx = cx + Math.cos(ga) * RX * 1.3, hy = cy + Math.sin(ga) * RY * 1.3; ctx.save(); ctx.strokeStyle = '#FF3B30'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(hx, hy, w / 13, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
label(ctx, '4.6 P/M²', hx, hy + w / 9, Math.max(9, w / 30), '#FF6B61', 'center');
|
||||
label(ctx, 'CROWD DENSITY · P/M²', w * 0.05, h * PT.topY, Math.max(8, w / 42), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
/* ---------- CCTV-style scenes (animated: static base cached, dynamic layer per frame) ---------- */
|
||||
const SCN = (PT.scenes = {});
|
||||
function cctvOSD(ctx, w, h, t, o) {
|
||||
const fs = Math.max(8, Math.round(w / 34)); const y0 = h * (o.osdY ?? PT.osdY), y1 = h * (o.osdB ?? PT.osdB);
|
||||
osd(ctx, o.cam || 'CAM 0412', w * 0.045, y0, fs); osd(ctx, PT.stamp(o.at ?? Date.now()), w * 0.955, y0, fs, 'right');
|
||||
if (o.place) osd(ctx, o.place, w * 0.045, y1 - fs, fs * 0.9);
|
||||
if (o.rec !== false && Math.floor(t * 1.2) % 2 === 0) { ctx.fillStyle = '#FF3B30'; ctx.beginPath(); ctx.arc(w * 0.955 - fs * 0.3, y1 - fs * 0.5, fs * 0.32, 0, TAU); ctx.fill(); osd(ctx, 'REC', w * 0.955 - fs * 0.9, y1 - fs, fs * 0.9, 'right'); }
|
||||
}
|
||||
PT.osdY = 0.115; PT.osdB = 0.87; PT.topY = 0.125; PT.botY = 0.86;
|
||||
PT.cctvOSD = cctvOSD;
|
||||
|
||||
// harbour at night: gantry cranes, a lit ship, fog, reflections, rain before a typhoon
|
||||
SCN.harbour = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 8); const N = noiseGen(8); const hz = h * 0.56;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#0b0d12'); sky.addColorStop(0.7, '#2a2522'); sky.addColorStop(1, '#5a4430'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
// far shore: city lights band
|
||||
ctx.fillStyle = '#121010'; ctx.fillRect(0, hz - h * 0.035, w, h * 0.04);
|
||||
for (let i = 0; i < 260; i++) { const x = R() * w, y = hz - R() * h * 0.05; ctx.fillStyle = `rgba(255,${170 + R() * 70},${90 + R() * 80},${0.4 + R() * 0.6})`; ctx.fillRect(x, y, 1.3, 1.3); }
|
||||
for (let i = 0; i < 9; i++) { const x = R() * w, bw = w * (0.02 + R() * 0.03), bh = h * (0.05 + R() * 0.12); ctx.fillStyle = '#0d0c0d'; ctx.fillRect(x, hz - bh, bw, bh); for (let k = 0; k < 18; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,220,160,${0.3 + R() * 0.5})`; ctx.fillRect(x + R() * bw, hz - R() * bh, 1.4, 1.4); } }
|
||||
// gantry cranes
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = w * (0.08 + i * 0.2) + R() * w * 0.04, s = h * (0.34 - i * 0.03), top = hz - s; ctx.strokeStyle = '#08080a'; ctx.fillStyle = '#08080a'; ctx.lineWidth = Math.max(2, w / 160);
|
||||
ctx.beginPath(); ctx.moveTo(x, hz + h * 0.04); ctx.lineTo(x + w * 0.012, top); ctx.moveTo(x + w * 0.07, hz + h * 0.04); ctx.lineTo(x + w * 0.058, top); ctx.stroke();
|
||||
ctx.fillRect(x - w * 0.09, top - h * 0.012, w * 0.26, h * 0.016); ctx.fillRect(x + w * 0.01, top - h * 0.06, w * 0.05, h * 0.05);
|
||||
ctx.lineWidth = 1; for (let k = 0; k < 8; k++) { ctx.beginPath(); ctx.moveTo(x + w * 0.012 * (k / 8) + 1, hz + h * 0.04 - (k * s) / 8); ctx.lineTo(x + w * 0.07 - w * 0.012 * ((k + 1) / 8), hz + h * 0.04 - ((k + 1) * s) / 8); ctx.stroke(); }
|
||||
for (let k = 0; k < 6; k++) { ctx.fillStyle = 'rgba(255,236,200,.85)'; ctx.fillRect(x - w * 0.08 + k * w * 0.045, top - h * 0.002, 2, 2); }
|
||||
}
|
||||
// container ship
|
||||
const sy = hz + h * 0.02; ctx.fillStyle = '#0d0f12'; ctx.beginPath(); ctx.moveTo(w * 0.12, sy); ctx.lineTo(w * 0.92, sy); ctx.lineTo(w * 0.88, sy + h * 0.07); ctx.lineTo(w * 0.15, sy + h * 0.07); ctx.closePath(); ctx.fill();
|
||||
const cols = ['#3b2420', '#1f2d3b', '#2b3326', '#3a3324', '#2a2a30', '#3a1f2a'];
|
||||
for (let i = 0; i < 46; i++) for (let j = 0; j < 4; j++) { if (R() < 0.12) continue; ctx.fillStyle = cols[R.int(cols.length)]; ctx.fillRect(w * 0.18 + i * w * 0.0145, sy - (j + 1) * h * 0.014, w * 0.013, h * 0.012); }
|
||||
ctx.fillStyle = '#16181c'; ctx.fillRect(w * 0.8, sy - h * 0.1, w * 0.07, h * 0.1); for (let k = 0; k < 14; k++) { ctx.fillStyle = 'rgba(255,230,180,.85)'; ctx.fillRect(w * 0.805 + (k % 7) * w * 0.009, sy - h * 0.09 + Math.floor(k / 7) * h * 0.02, 2, 2); }
|
||||
// water
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#15120f'); wg.addColorStop(1, '#050506'); ctx.fillStyle = wg; ctx.fillRect(0, sy + h * 0.07, w, h);
|
||||
// quay & lamps in the foreground
|
||||
ctx.fillStyle = '#0a0a0b'; ctx.beginPath(); ctx.moveTo(0, h * 0.86); ctx.lineTo(w, h * 0.8); ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.fill();
|
||||
for (let i = 0; i < 4; i++) { const x = w * (0.1 + i * 0.28), y = h * (0.86 - i * 0.015); ctx.fillStyle = '#060606'; ctx.fillRect(x, y - h * 0.16, 2, h * 0.16); }
|
||||
// fog
|
||||
for (let y = 0; y < h; y += 4) { const f = Math.exp(-(((y - hz) / (h * 0.12)) ** 2)) * 0.18; ctx.fillStyle = `rgba(150,130,110,${f})`; ctx.fillRect(0, y, w, 4); }
|
||||
c.lights = []; for (let i = 0; i < 4; i++) c.lights.push([w * (0.1 + i * 0.28) + 1, h * (0.86 - i * 0.015) - h * 0.16]);
|
||||
c.hz = hz; c.sy = sy + h * 0.07; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
ctx.drawImage(B, 0, 0); const N = this._n || (this._n = noiseGen(4)); const R = rng(Math.floor(t * 8));
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
// reflections shimmer
|
||||
for (let y = B.sy; y < h * 0.86; y += 2) { const f = (y - B.sy) / (h * 0.86 - B.sy); for (const [lx, a] of [[0.83, 0.35], [0.3, 0.12], [0.55, 0.1], [0.12, 0.08]]) { const j = (N(y * 0.15, t * 2 + lx * 9) - 0.5) * 16 * (0.5 + f); ctx.fillStyle = `rgba(255,170,90,${a * (1 - f * 0.7)})`; ctx.fillRect(lx * w + j - 3 - f * 8, y, 6 + f * 16, 1.3); } }
|
||||
for (const [x, y] of B.lights) glow(ctx, x, y, w * 0.06, 'rgba(255,170,80,A)', 0.55);
|
||||
for (let i = 0; i < 4; i++) { const on = Math.sin(t * 3 + i * 1.7) > 0; if (!on) continue; glow(ctx, w * (0.1 + i * 0.2) + w * 0.035, B.hz - h * (0.34 - i * 0.03) - h * 0.065, w * 0.025, 'rgba(255,40,30,A)', 0.9); }
|
||||
ctx.restore();
|
||||
// light rain
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(200,200,210,.18)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < 90; i++) { const x = (R() * w + t * 60) % w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 3, y + h * 0.03); } ctx.stroke(); ctx.restore();
|
||||
scan(ctx, w, h, 0.06); vignette(ctx, w, h, 0.5); grain(ctx, w, h, 0.12);
|
||||
cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// scramble crossing at night in the rain: umbrellas from a high camera, neon on wet asphalt
|
||||
SCN.crossing = {
|
||||
// high camera over a scramble: ground plane in perspective, crowds waiting and crossing in the rain
|
||||
map(w, h) { const top = h * 0.25, zF = 2.3, zN = 1; return (u, v) => { const z = lerp(zF, zN, v); const y = top + (h - top) * ((1 / z - 1 / zF) / (1 / zN - 1 / zF)); return [w / 2 + ((u - 0.5) * w * 1.3) / z, y, 1 / z]; }; },
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 31); const M = this.map(w, h); const top = h * 0.25;
|
||||
// facades, video screens and signs (abstract, no marks)
|
||||
const fg = ctx.createLinearGradient(0, 0, 0, top); fg.addColorStop(0, '#060609'); fg.addColorStop(1, '#141218'); ctx.fillStyle = fg; ctx.fillRect(0, 0, w, top + 2);
|
||||
for (let i = 0; i < 7; i++) { const x = (i / 7) * w + R() * 10, bw = w / 7 - 4; ctx.fillStyle = `rgb(${12 + R() * 10},${12 + R() * 8},${16 + R() * 10})`; ctx.fillRect(x, R() * top * 0.2, bw, top); for (let k = 0; k < 40; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,${210 + R() * 40},${160 + R() * 70},${0.25 + R() * 0.4})`; ctx.fillRect(x + R() * bw, R() * top, 2, 1.5); } }
|
||||
const neon = ['255,70,130', '70,200,255', '255,190,70', '170,110,255', '90,255,190'];
|
||||
const screens = []; for (let i = 0; i < 4; i++) { const x = w * (0.05 + i * 0.25) + R() * 10, y = top * (0.14 + R() * 0.3), sw = w * (0.09 + R() * 0.05), sh = top * (0.22 + R() * 0.16); screens.push([x, y, sw, sh, neon[R.int(5)], neon[R.int(5)]]); }
|
||||
for (const [x, y, sw, sh, a, b] of screens) { ctx.fillStyle = '#050507'; ctx.fillRect(x - 3, y - 3, sw + 6, sh + 6); const g = ctx.createLinearGradient(x, y, x + sw, y + sh); g.addColorStop(0, `rgba(${a},.75)`); g.addColorStop(0.6, `rgba(${b},.55)`); g.addColorStop(1, `rgba(${a},.35)`); ctx.fillStyle = g; ctx.fillRect(x, y, sw, sh); ctx.fillStyle = 'rgba(255,255,255,.35)'; for (let k = 0; k < 3; k++) ctx.fillRect(x + sw * 0.1, y + sh * (0.55 + k * 0.12), sw * (0.3 + R() * 0.5), Math.max(1, sh * 0.05)); ctx.fillStyle = 'rgba(0,0,0,.25)'; for (let yy = y; yy < y + sh; yy += 2) ctx.fillRect(x, yy, sw, 1); ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, x + sw / 2, y + sh / 2, sw * 0.8, `rgba(${a},A)`, 0.18); ctx.restore(); }
|
||||
for (let i = 0; i < 9; i++) { const x = R() * w, y = R() * top * 0.7; ctx.fillStyle = `rgba(${neon[R.int(5)]},.6)`; ctx.fillRect(x, y, w * 0.008, top * (0.15 + R() * 0.25)); }
|
||||
// asphalt
|
||||
ctx.fillStyle = '#121117'; ctx.beginPath(); const q = [M(0, 0), M(1, 0), M(1, 1), M(0, 1)]; ctx.moveTo(-w, top); ctx.lineTo(w * 2, top); ctx.lineTo(w * 2, h); ctx.lineTo(-w, h); ctx.fill();
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (const [x, y, sw, sh, a] of screens) { const g = ctx.createLinearGradient(0, top, 0, h); g.addColorStop(0, `rgba(${a},.30)`); g.addColorStop(0.6, `rgba(${a},.05)`); g.addColorStop(1, `rgba(${a},0)`); ctx.fillStyle = g; for (let k = 0; k < 6; k++) ctx.fillRect(x + sw * (0.1 + k * 0.15) + R.gauss() * 3, top, sw * 0.08, h - top); }
|
||||
ctx.restore();
|
||||
// crosswalk stripes as quads on the ground plane
|
||||
const zebra = (u1, v1, u2, v2, n, wd) => { const du = u2 - u1, dv = v2 - v1, L = Math.hypot(du, dv); const nu = -dv / L * wd, nv = du / L * wd; ctx.fillStyle = 'rgba(205,208,218,.26)'; for (let i = 0; i < n; i++) { const t0 = (i + 0.15) / n, t1 = (i + 0.62) / n; const a = M(u1 + du * t0 + nu, v1 + dv * t0 + nv), b = M(u1 + du * t1 + nu, v1 + dv * t1 + nv), cc = M(u1 + du * t1 - nu, v1 + dv * t1 - nv), d = M(u1 + du * t0 - nu, v1 + dv * t0 - nv); poly(ctx, [a, b, cc, d], true); ctx.fill(); } };
|
||||
zebra(0.1, 0.14, 0.9, 0.14, 26, 0.06); zebra(0.08, 0.9, 0.92, 0.9, 26, 0.07); zebra(0.08, 0.2, 0.08, 0.84, 20, 0.055); zebra(0.92, 0.2, 0.92, 0.84, 20, 0.055); zebra(0.17, 0.22, 0.83, 0.82, 30, 0.028); zebra(0.83, 0.22, 0.17, 0.82, 30, 0.028);
|
||||
// kerbs/corner pavements
|
||||
ctx.fillStyle = '#1d1c22'; for (const [u0, v0] of [[-0.3, -0.2], [1.0, -0.2], [-0.3, 1.0], [1.0, 1.0]]) { poly(ctx, [M(u0, v0), M(u0 + 0.3, v0), M(u0 + 0.3, v0 + 0.2), M(u0, v0 + 0.2)], true); ctx.fill(); }
|
||||
// vehicles waiting, lights on
|
||||
for (const [u, v, d] of [[-0.05, 0.4, 1], [-0.05, 0.55, 1], [-0.05, 0.7, 1], [1.05, 0.35, -1], [1.05, 0.5, -1], [0.35, -0.05, 0], [0.55, 1.08, 0]]) { const [x, y, s] = M(u, v); ctx.fillStyle = '#18181d'; ctx.fillRect(x - 22 * s, y - 9 * s, 44 * s, 18 * s); ctx.save(); ctx.globalCompositeOperation = 'lighter'; if (d >= 0) { glow(ctx, x + 22 * s, y - 5 * s, 26 * s, 'rgba(255,248,225,A)', 0.8); glow(ctx, x + 22 * s, y + 5 * s, 26 * s, 'rgba(255,248,225,A)', 0.8); } else { glow(ctx, x + 22 * s, y, 18 * s, 'rgba(255,40,40,A)', 0.7); } ctx.restore(); }
|
||||
// people
|
||||
const P = []; const corners = [[0.06, 0.08], [0.94, 0.08], [0.06, 0.94], [0.94, 0.94]]; const cols = ['226,60,72', '34,44,64', '236,236,242', '56,134,228', '248,188,44', '26,26,32', '118,196,124', '240,120,170', '40,40,46', '190,190,200'];
|
||||
for (let i = 0; i < 520; i++) { const wait = i < 170; const ci = i % 4; const a = corners[ci], b = wait ? a : corners[[3, 2, 1, 0, 1, 0, 3, 2][(i >> 2) % 8] ?? 3 - ci]; const umb = R() < 0.72; P.push({ a, b, wait, off: R(), sp: 0.035 + R() * 0.03, jx: R.gauss() * (wait ? 0.035 : 0.03), jy: R.gauss() * (wait ? 0.03 : 0.022), umb, col: cols[R.int(cols.length)], s: 0.8 + R() * 0.45 }); }
|
||||
c.P = P; c.M = M; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
ctx.drawImage(B, 0, 0); const M = B.M; const list = [];
|
||||
for (const p of B.P) { const u = p.wait ? 0 : (p.off + t * p.sp) % 1; const [x, y, s] = M(lerp(p.a[0], p.b[0], u) + p.jx, lerp(p.a[1], p.b[1], u) + p.jy); list.push([y, x, s * (w / 46) * p.s, p]); }
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
for (const [y, x, s, p] of list) { if (p.umb) { ctx.fillStyle = `rgba(${p.col},.95)`; ctx.beginPath(); ctx.ellipse(x, y, s * 1.1, s * 0.82, 0, 0, TAU); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,.16)'; ctx.beginPath(); ctx.ellipse(x - s * 0.25, y - s * 0.22, s * 0.5, s * 0.32, 0, 0, TAU); ctx.fill(); } else { ctx.fillStyle = '#17161b'; ctx.beginPath(); ctx.ellipse(x, y + s * 0.2, s * 0.55, s * 0.4, 0, 0, TAU); ctx.fill(); ctx.fillStyle = '#2b2622'; ctx.beginPath(); ctx.arc(x, y - s * 0.1, s * 0.33, 0, TAU); ctx.fill(); } }
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(210,215,230,.15)'; ctx.lineWidth = 1; const R = rng(Math.floor(t * 10)); ctx.beginPath(); for (let i = 0; i < 160; i++) { const x = R() * w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 2, y + h * 0.025); } ctx.stroke(); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45); scan(ctx, w, h, 0.05); grain(ctx, w, h, 0.1);
|
||||
if (!o.noOSD) cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// waterfront in a storm at night: looking along the wall, swells breaking over it, sodium lamps on a
|
||||
// flooded road, a town behind; handheld when a person films it
|
||||
SCN.seawall = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 51); const N = noiseGen(o.seed || 51); const hz = h * 0.4;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#06070a'); sky.addColorStop(1, o.skyline ? '#2a2129' : '#191a20'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz + 2);
|
||||
field(ctx, w, hz, 3, (u, v, out) => { const n = N.fbm(u * 2.5 + v, v * 4, 5); out[0] = 34 + n * 40; out[1] = 32 + n * 38; out[2] = 38 + n * 40; out[3] = 150 * smooth(0.35, 0.8, n); });
|
||||
// town / skyline on the right half, beyond the road
|
||||
const town = (x0, x1, tall) => { for (let i = 0; i < (tall ? 26 : 34); i++) { const x = lerp(x0, x1, R()), bw = w * (tall ? 0.02 + R() * 0.035 : 0.025 + R() * 0.04), bh = h * (tall ? 0.04 + Math.pow(R(), 2) * 0.24 : 0.02 + R() * 0.05); ctx.fillStyle = '#0b0a0d'; ctx.fillRect(x, hz - bh, bw, bh + 2); const n = Math.floor(bw * bh * 0.012); for (let k = 0; k < n; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,${190 + R() * 60},${120 + R() * 90},${0.3 + R() * 0.6})`; ctx.fillRect(x + R() * bw, hz - R() * bh, 1.4, 1.4); } } };
|
||||
town(w * 0.5, w * 1.0, !!o.skyline);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.8, hz, w * 0.5, 'rgba(255,140,60,A)', o.skyline ? 0.22 : 0.12); ctx.restore();
|
||||
// sea on the left: dark swells with streaked foam, perspective-compressed toward the horizon
|
||||
field(ctx, w, h, 2, (u, v, out) => { const y = v * h; if (y < hz) { out[3] = 0; return; } const d = (y - hz) / (h - hz); const z = 1 / (0.05 + d); const n = N.fbm(u * 3 * z * 0.25 + 3, d * 9 + z * 0.15, 5); const sw = Math.sin(z * 1.6 + n * 4); const crest = smooth(0.7, 0.98, sw) * smooth(0.45, 0.7, n); const foam = smooth(0.68, 0.82, N(u * 30 * z * 0.1, d * 60)) * 0.35 * smooth(0.5, 0.75, n);
|
||||
out[0] = 10 + n * 22 + (crest + foam) * 170; out[1] = 13 + n * 25 + (crest + foam) * 172; out[2] = 18 + n * 28 + (crest + foam) * 178; out[3] = 255; });
|
||||
// the wall: a parapet running from bottom-left toward the horizon; road + puddles on its right
|
||||
const vp = [w * 0.56, hz + h * 0.005]; const L0 = [w * -0.02, h * 1.0], L1 = [w * 0.22, h * 1.0];
|
||||
ctx.fillStyle = '#1e1d1f'; poly(ctx, [L0, vp, [vp[0] + 2, vp[1]], L1], true); ctx.fill(); // wall top
|
||||
const road = [[w * 0.22, h], [vp[0] + 2, vp[1]], [w * 0.9, vp[1] + h * 0.02], [w * 1.2, h]]; const rg = ctx.createLinearGradient(0, hz, 0, h); rg.addColorStop(0, '#141316'); rg.addColorStop(1, '#0a0a0b'); ctx.fillStyle = rg; poly(ctx, road, true); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(200,190,170,.18)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(L1[0], L1[1]); ctx.lineTo(vp[0] + 2, vp[1]); ctx.stroke();
|
||||
// lamps along the road edge
|
||||
const lamps = []; for (let i = 0; i < 7; i++) { const f = Math.pow(i / 7, 0.8); const bx = lerp(w * 0.5, vp[0] + w * 0.02, f), by = lerp(h * 1.0, vp[1] + h * 0.004, f); const lh = lerp(h * 0.5, h * 0.02, f); ctx.fillStyle = '#050506'; ctx.fillRect(bx, by - lh, Math.max(1.2, 6 * (1 - f)), lh); ctx.fillRect(bx - lh * 0.12, by - lh, lh * 0.14, Math.max(1, 4 * (1 - f))); lamps.push([bx - lh * 0.1, by - lh + 3, 1 - f, by]); }
|
||||
if (o.palm) { ctx.save(); ctx.translate(w * 0.9, h * 0.98); ctx.strokeStyle = '#040404'; ctx.lineWidth = w / 45; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(-w * 0.02, -h * 0.3, -w * 0.12, -h * 0.5); ctx.stroke(); ctx.lineWidth = w / 160; for (let k = 0; k < 9; k++) { const a = -2.6 + k * 0.28; ctx.beginPath(); ctx.moveTo(-w * 0.12, -h * 0.5); ctx.quadraticCurveTo(-w * 0.12 + Math.cos(a) * w * 0.08, -h * 0.5 + Math.sin(a) * h * 0.04, -w * 0.12 + Math.cos(a) * w * 0.17 - w * 0.05, -h * 0.5 + Math.sin(a) * h * 0.06 + h * 0.06); ctx.stroke(); } ctx.restore(); }
|
||||
c.lamps = lamps; c.hz = hz; c.vp = vp; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
const shake = o.handheld ? [Math.sin(t * 7.1) * w * 0.004 + Math.sin(t * 2.3) * w * 0.006, Math.cos(t * 6.3) * h * 0.003] : [0, 0];
|
||||
ctx.save(); ctx.translate(shake[0], shake[1]); ctx.drawImage(B, -4, -4, w + 8, h + 8);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (const [x, y, f, gy] of B.lamps) { glow(ctx, x, y, w * 0.14 * (0.3 + f), 'rgba(255,150,50,A)', 0.55 + 0.1 * Math.sin(t * 13 + x)); const g = ctx.createLinearGradient(0, gy - h * 0.01, 0, gy + h * 0.16 * f); g.addColorStop(0, 'rgba(255,150,60,.0)'); g.addColorStop(0.2, 'rgba(255,150,60,.3)'); g.addColorStop(1, 'rgba(255,150,60,0)'); ctx.fillStyle = g; ctx.fillRect(x - w * 0.012 * (0.5 + f), gy - h * 0.01, w * 0.024 * (0.5 + f) + 1.5, h * 0.17 * f); }
|
||||
// breaking swells: spray sheets rise off the wall and blow right with the wind
|
||||
const R0 = rng(7); for (let b = 0; b < 3; b++) { const per = 3.6 + b * 1.3; const tt = t + b * 1.7; const ph = (tt % per) / per; if (ph > 0.6) continue; const k = Math.sin((ph / 0.6) * Math.PI); const f = [0.18, 0.42, 0.68][b]; const bx = lerp(w * 0.08, B.vp[0], f), by = lerp(h * 0.98, B.vp[1], f); const sz = (1 - f * 0.85) * h * 0.55; const R = rng(b * 97 + Math.floor(tt / per));
|
||||
for (let i = 0; i < 90; i++) { const up = Math.pow(R(), 0.7) * sz * k, side = (R() - 0.35) * sz * 0.35 + up * 0.6 * ph; const x = bx + side, y = by - up; const r = (0.002 + R() * 0.007) * w * (1 - f * 0.6) * (0.6 + k); ctx.fillStyle = `rgba(210,218,230,${(0.04 + R() * 0.1) * k})`; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = `rgba(220,226,236,${0.08 * k})`; ctx.lineWidth = Math.max(1, w / 400); ctx.beginPath(); for (let i = 0; i < 40; i++) { const up = R() * sz * k; const x = bx + (R() - 0.3) * sz * 0.3, y = by - up * 0.3; ctx.moveTo(x, y); ctx.lineTo(x + sz * 0.12 * ph + R() * 6, y - up * 0.7); } ctx.stroke();
|
||||
const g = ctx.createRadialGradient(bx + sz * 0.25 * ph, by - sz * 0.35 * k, 0, bx + sz * 0.25 * ph, by - sz * 0.35 * k, sz * 0.55); g.addColorStop(0, `rgba(190,198,212,${0.13 * k})`); g.addColorStop(1, 'rgba(190,198,212,0)'); ctx.fillStyle = g; ctx.fillRect(bx - sz, by - sz * 1.2, sz * 2.2, sz * 1.4); }
|
||||
ctx.restore();
|
||||
// rain, wind-driven, brighter near the lamps
|
||||
const R = rng(Math.floor(t * 24)); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(205,212,228,.26)'; ctx.beginPath(); for (let i = 0; i < 320; i++) { const x = R() * w * 1.25, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - w * 0.035, y + h * 0.05); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(255,190,120,.35)'; ctx.beginPath(); for (const [x, y, f] of B.lamps) for (let i = 0; i < 10 * f; i++) { const xx = x + (R() - 0.5) * w * 0.2 * f, yy = y + (R() - 0.3) * h * 0.2 * f; ctx.moveTo(xx, yy); ctx.lineTo(xx - w * 0.02, yy + h * 0.03); } ctx.stroke();
|
||||
ctx.restore();
|
||||
vignette(ctx, w, h, 0.55); grain(ctx, w, h, 0.12);
|
||||
if (!o.noOSD) cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// a quiet volcano webcam by day: ash column over the bay
|
||||
SCN.volcano = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const N = noiseGen(12); const R = rng(12); const hz = h * 0.66;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#6f8fb0'); sky.addColorStop(1, '#c9cfd2'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
// ash plume
|
||||
const vx = w * 0.46, vy = hz - h * 0.3;
|
||||
field(ctx, w, h, 2, (u, v, out) => { const x = u * w - vx, y = v * h - vy; if (y > 4) { out[3] = 0; return; } const up = -y; const drift = up * 0.55; const wd = 10 + up * 0.32; const n = N.fbm((x - drift) / 40, up / 40, 5); const d = Math.exp(-(((x - drift) / wd) ** 2) * (1.2 - n)) * smooth(0, 20, up) * (0.4 + 0.9 * n) * Math.exp(-up / (h * 1.2)); const a = clamp(d * 1.3, 0, 0.95); const sh = 0.6 + (x - drift) / wd * 0.15; out[0] = (120 + n * 70) * sh; out[1] = (108 + n * 62) * sh; out[2] = (98 + n * 58) * sh; out[3] = a * 255; });
|
||||
// cone
|
||||
const prof = (x) => { const d = Math.abs(x - vx) / w; return vy + (d < 0.035 ? (0.035 - d) * h * 0.25 : 0) + Math.pow(d, 1.35) * h * 0.95 + N(x / 22, 0) * 5; };
|
||||
field(ctx, w, h, 1.5, (u, v, out) => { const x = u * w, y = v * h; const top = prof(x); if (y < top || y > hz + 2) { out[3] = 0; return; } const d = (x - vx) / w; const n = N.fbm(u * 14, v * 6, 4); const gul = Math.pow(Math.abs(Math.sin(d * 60 + n * 3)), 6); const shade = 0.75 + d * 1.1 - gul * 0.25; const veg = smooth(0.08, 0.22, (y - top) / h) * smooth(0.4, 0.7, n); out[0] = (78 + n * 30) * shade - veg * 20; out[1] = (72 + n * 26) * shade + veg * 6; out[2] = (66 + n * 22) * shade - veg * 10; out[3] = 255; });
|
||||
// bay
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#7f9aa8'); wg.addColorStop(1, '#3d5664'); ctx.fillStyle = wg; ctx.fillRect(0, hz, w, h - hz);
|
||||
for (let y = hz; y < h; y += 2) { ctx.fillStyle = `rgba(255,255,255,${0.04 + R() * 0.05})`; ctx.fillRect(R() * w, y, w * 0.2 * R(), 1); }
|
||||
// town on the near shore
|
||||
ctx.fillStyle = '#2f3438'; ctx.fillRect(0, h * 0.86, w, h * 0.14); for (let i = 0; i < 60; i++) { const x = R() * w, bw = w * (0.02 + R() * 0.04), bh = h * (0.02 + R() * 0.06); ctx.fillStyle = ['#d8d4cc', '#b9b4aa', '#8e8a84', '#6c6a68'][R.int(4)]; ctx.fillRect(x, h * 0.88 - bh, bw, bh); }
|
||||
return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) { ctx.drawImage(B, 0, 0); vignette(ctx, w, h, 0.25); grain(ctx, w, h, 0.07); if (!o.noOSD) cctvOSD(ctx, w, h, t, o); },
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
INSIDE — what a person saw
|
||||
===================================================================================== */
|
||||
PT.aurora = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 61); const N = noiseGen(61); const hz = h * 0.66;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#02030a'); sky.addColorStop(1, '#0b1a22'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 500; i++) { ctx.fillStyle = `rgba(255,255,255,${R() * 0.8})`; ctx.fillRect(R() * w, R() * hz, 1, 1); }
|
||||
// curtains (per-pixel: base line, height and ray striation per column)
|
||||
const curtains = [[0.42, 0.06, 1.3, [170, 80, 255]], [0.55, 0.04, 4.2, [200, 90, 255]]];
|
||||
field(ctx, w, h, 3, (u, v, out) => { let r = 0, g = 0, b = 0; const y = v * h; for (const [yb, amp, sd, top] of curtains) { const base = h * yb + Math.sin(u * 5 + sd) * amp * h + N(u * 6, sd) * amp * h * 1.4; const ht = h * (0.18 + 0.28 * N.fbm(u * 9 + sd, sd, 3)); const f = (base - y) / ht; if (f < -0.03 || f > 1) continue; const ray = 0.35 + 0.65 * Math.pow(N(u * 70, sd * 3), 2); const low = f < 0 ? smooth(-0.03, 0, f) : 1; const gk = Math.exp(-f * 3.2) * low * ray, pk = smooth(0.25, 0.9, f) * (1 - f) * ray * 0.6; r += gk * 60 + pk * top[0]; g += gk * 255 + pk * top[1] * 0.3; b += gk * 140 + pk * top[2]; } out[0] = r * 0.7; out[1] = g * 0.7; out[2] = b * 0.7; out[3] = 255; }, true, 'lighter');
|
||||
// mountains + fjord reflection
|
||||
ctx.fillStyle = '#05070a'; ctx.beginPath(); ctx.moveTo(0, hz); for (let x = 0; x <= w; x += 3) ctx.lineTo(x, hz - h * 0.05 - N.ridge(x / w * 3, 2, 4) * h * 0.16); ctx.lineTo(w, hz); ctx.fill();
|
||||
const img = ctx.getImageData(0, 0, w, Math.floor(hz)); const ref = mk(w, Math.floor(hz)); ref.getContext('2d').putImageData(img, 0, 0);
|
||||
ctx.save(); ctx.translate(0, hz * 2); ctx.scale(1, -1); ctx.globalAlpha = 0.45; for (let y = 0; y < hz; y += 3) { const j = Math.sin(y * 0.4) * 2; ctx.drawImage(ref, 0, y, w, 3, j, y, w, 3); } ctx.restore();
|
||||
ctx.fillStyle = 'rgba(2,4,8,.35)'; ctx.fillRect(0, hz, w, h - hz);
|
||||
ctx.fillStyle = '#020305'; ctx.fillRect(0, h * 0.9, w, h * 0.1); for (let i = 0; i < 26; i++) { const x = R() * w, th = h * (0.06 + R() * 0.1); ctx.beginPath(); ctx.moveTo(x, h * 0.9 - th); ctx.lineTo(x - th * 0.22, h * 0.91); ctx.lineTo(x + th * 0.22, h * 0.91); ctx.fill(); }
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.7, h * 0.885, w * 0.05, 'rgba(255,190,110,A)', 0.8); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45); grain(ctx, w, h, 0.12);
|
||||
};
|
||||
|
||||
// a hillside on fire, from the road
|
||||
PT.firewit = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 71); const N = noiseGen(71);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h * 0.7); sky.addColorStop(0, '#07060a'); sky.addColorStop(0.7, '#3a1a10'); sky.addColorStop(1, '#8a3a12'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
// smoke lit from below
|
||||
field(ctx, w, h * 0.7, 3, (u, v, out) => { const n = N.fbm(u * 3 + 0.5 * v, v * 4, 5); const lit = smooth(0.2, 0.95, v); out[0] = 40 + n * 90 * lit + 60 * lit; out[1] = 25 + n * 45 * lit; out[2] = 20 + n * 25 * lit; out[3] = 255 * smooth(0.35, 0.75, n) * 0.85; });
|
||||
const ridge = (x) => h * 0.52 - N.fbm(x / w * 2.4, 3, 4) * h * 0.22 + (x / w) * h * 0.06;
|
||||
ctx.fillStyle = '#060404'; ctx.beginPath(); ctx.moveTo(0, h); for (let x = 0; x <= w; x += 3) ctx.lineTo(x, ridge(x)); ctx.lineTo(w, h); ctx.fill();
|
||||
// flames along the ridge
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let x = 0; x < w; x += 2) { const y = ridge(x); const k = smooth(0.25, 0.6, N(x / 40, 7)); if (k < 0.05) continue; const fh = h * (0.02 + 0.07 * N(x / 9, 2)) * k; const g = ctx.createLinearGradient(0, y, 0, y - fh); g.addColorStop(0, `rgba(255,220,120,${0.9 * k})`); g.addColorStop(0.4, `rgba(255,120,30,${0.7 * k})`); g.addColorStop(1, 'rgba(200,40,10,0)'); ctx.fillStyle = g; ctx.fillRect(x, y - fh, 2.4, fh + 2); }
|
||||
for (let x = 0; x < w; x += w / 14) { const y = ridge(x); glow(ctx, x, y, w * 0.12, 'rgba(255,100,20,A)', 0.25 * smooth(0.25, 0.6, N(x / 40, 7))); }
|
||||
for (let i = 0; i < 120; i++) { const x = R() * w, y = R() * h * 0.55; ctx.fillStyle = `rgba(255,${150 + R() * 80},60,${R() * 0.8})`; ctx.fillRect(x, y, 1.6, 1.6); }
|
||||
ctx.restore();
|
||||
// road & tail lights
|
||||
ctx.fillStyle = '#0b0b0c'; ctx.beginPath(); ctx.moveTo(0, h); ctx.lineTo(w * 0.42, h * 0.74); ctx.lineTo(w * 0.52, h * 0.74); ctx.lineTo(w, h); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,220,120,.4)'; ctx.setLineDash([8, 10]); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w * 0.62, h); ctx.lineTo(w * 0.475, h * 0.745); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const [x, y, s] of [[0.36, 0.86, 1], [0.44, 0.8, 0.6], [0.47, 0.77, 0.4]]) { glow(ctx, x * w, y * h, w * 0.04 * s, 'rgba(255,30,30,A)', 0.8); glow(ctx, x * w + w * 0.06 * s, y * h, w * 0.04 * s, 'rgba(255,30,30,A)', 0.8); } ctx.restore();
|
||||
vignette(ctx, w, h, 0.5); grain(ctx, w, h, 0.13);
|
||||
};
|
||||
|
||||
// inside the cabin of a diverting airliner: masks down, emergency floor lighting, a calm crew
|
||||
PT.cabin = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 81); const vx = w * 0.52, vy = h * 0.42;
|
||||
const bg = ctx.createRadialGradient(vx, vy, 0, vx, vy, h); bg.addColorStop(0, '#5a5040'); bg.addColorStop(0.35, '#2a2620'); bg.addColorStop(1, '#0c0b0a'); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const P = (x, y, z) => [vx + (x - vx) * z, vy + (y - vy) * z];
|
||||
// ceiling & bins
|
||||
ctx.fillStyle = '#1e1c1a'; poly(ctx, [[0, 0], [w, 0], P(w, 0, 0.12), P(0, 0, 0.12)], true); ctx.fill();
|
||||
for (const side of [0, 1]) { const x0 = side ? w : 0; ctx.fillStyle = '#2c2a27'; poly(ctx, [[x0, h * 0.05], P(x0, h * 0.05, 0.12), P(x0, h * 0.3, 0.12), [x0, h * 0.3]], true); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,236,200,.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, h * 0.3); const e = P(x0, h * 0.3, 0.12); ctx.lineTo(e[0], e[1]); ctx.stroke(); }
|
||||
// windows (dark night outside, faint blue)
|
||||
for (let i = 0; i < 9; i++) { const z = 1 / (1 + i * 0.55); for (const side of [0, 1]) { const x0 = side ? w : 0; const [x, y] = P(x0, h * 0.36, z); const ww = w * 0.05 * z, wh = h * 0.08 * z; ctx.fillStyle = '#0b1424'; ctx.beginPath(); ctx.ellipse(x + (side ? -ww : ww), y, ww * 0.5, wh * 0.5, 0, 0, TAU); ctx.fill(); } }
|
||||
// seat rows receding
|
||||
for (let i = 9; i >= 0; i--) { const z = 1 / (1 + i * 0.55); for (const side of [0, 1]) { for (let k = 0; k < 3; k++) { const sx = side ? lerp(w * 0.62, w * 1.05, k / 2.4) : lerp(w * 0.42, -w * 0.05, k / 2.4); const [x, y] = P(sx, h * 0.6, z); const sw = w * 0.13 * z, sh = h * 0.3 * z; ctx.fillStyle = `rgb(${18 + i * 2},${24 + i * 2},${44 + i * 2})`; ctx.beginPath(); ctx.roundRect(x - sw / 2, y - sh * 0.55, sw * 0.92, sh, sw * 0.18); ctx.fill(); ctx.fillStyle = `rgba(210,205,190,${0.5 - i * 0.03})`; ctx.fillRect(x - sw * 0.38, y - sh * 0.52, sw * 0.7, sh * 0.12); if (R() < 0.75) { ctx.fillStyle = `rgba(${40 + R() * 40},${30 + R() * 30},${25 + R() * 20},.95)`; ctx.beginPath(); ctx.arc(x - sw * 0.05, y - sh * 0.62, sw * 0.2, 0, TAU); ctx.fill(); } } } }
|
||||
// oxygen masks hanging on their tubes
|
||||
for (let i = 0; i < 26; i++) { const z = 1 / (1 + R() * 5); const side = R() < 0.5; const sx = side ? lerp(w * 0.62, w * 1.0, R()) : lerp(w * 0.42, 0, R()); const [x, y0] = P(sx, h * 0.3, z); const L = h * (0.12 + R() * 0.16) * z; const sway = (R() - 0.5) * 10 * z; ctx.strokeStyle = 'rgba(240,240,230,.7)'; ctx.lineWidth = Math.max(0.6, 1.4 * z); ctx.beginPath(); ctx.moveTo(x, y0); ctx.quadraticCurveTo(x + sway, y0 + L * 0.6, x + sway * 0.6, y0 + L); ctx.stroke(); ctx.fillStyle = '#F4C21A'; ctx.beginPath(); ctx.ellipse(x + sway * 0.6, y0 + L + 5 * z, 7 * z * (w / 400), 9 * z * (w / 400), 0, 0, TAU); ctx.fill(); }
|
||||
// aisle with emergency floor path lighting
|
||||
ctx.fillStyle = '#161412'; poly(ctx, [[w * 0.36, h], [w * 0.68, h], P(w * 0.56, h, 0.1), P(w * 0.48, h, 0.1)], true); ctx.fill();
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let i = 0; i < 18; i++) { const z = 1 / (1 + i * 0.4); for (const xx of [w * 0.38, w * 0.66]) { const [x, y] = P(xx, h * 0.99, z); glow(ctx, x, y, 10 * z * (w / 300), 'rgba(255,170,70,A)', 0.9); } } glow(ctx, vx, vy - h * 0.05, w * 0.3, 'rgba(255,220,170,A)', 0.25); ctx.restore();
|
||||
// a crew member down the aisle
|
||||
const [cx, cy] = P(w * 0.52, h * 0.66, 0.42); ctx.fillStyle = '#0d0c10'; ctx.beginPath(); ctx.roundRect(cx - w * 0.03, cy - h * 0.16, w * 0.06, h * 0.17, 6); ctx.fill(); ctx.beginPath(); ctx.arc(cx, cy - h * 0.18, w * 0.022, 0, TAU); ctx.fill();
|
||||
// motion blur feel + grain
|
||||
ctx.save(); ctx.globalAlpha = 0.18; ctx.drawImage(cv, 3, 1); ctx.restore();
|
||||
vignette(ctx, w, h, 0.6); grain(ctx, w, h, 0.16);
|
||||
};
|
||||
|
||||
// a city block after the power went out: one building on generator light
|
||||
PT.blackout = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 91);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h); sky.addColorStop(0, '#05060a'); sky.addColorStop(1, '#10121a'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 7; i++) { const x = i * w / 6 - 10, bw = w / 6 + 10, bh = h * (0.25 + R() * 0.3); ctx.fillStyle = '#08090c'; ctx.fillRect(x, h * 0.72 - bh, bw, bh); }
|
||||
const hx = w * 0.56, hy = h * 0.38; ctx.fillStyle = '#121418'; ctx.fillRect(hx - w * 0.16, hy, w * 0.32, h * 0.34);
|
||||
for (let r = 0; r < 7; r++) for (let c = 0; c < 10; c++) { if (R() < 0.45) continue; ctx.fillStyle = `rgba(255,${225 + R() * 30},${180 + R() * 50},${0.45 + R() * 0.5})`; ctx.fillRect(hx - w * 0.145 + c * w * 0.03, hy + h * 0.025 + r * h * 0.042, w * 0.016, h * 0.022); }
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, hx, hy + h * 0.15, w * 0.4, 'rgba(255,230,190,A)', 0.18); glow(ctx, w * 0.2, h * 0.86, w * 0.1, 'rgba(255,40,40,A)', 0.6); ctx.restore();
|
||||
const rg = ctx.createLinearGradient(0, h * 0.72, 0, h); rg.addColorStop(0, '#0b0c10'); rg.addColorStop(1, '#030304'); ctx.fillStyle = rg; ctx.fillRect(0, h * 0.72, w, h * 0.28);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.25; for (let y = h * 0.74; y < h; y += 2) { ctx.fillStyle = 'rgba(255,220,170,.5)'; ctx.fillRect(hx - w * 0.08 + Math.sin(y) * 4, y, w * 0.16, 1); } ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(200,210,230,.2)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < 160; i++) { const x = R() * w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 4, y + 14); } ctx.stroke();
|
||||
vignette(ctx, w, h, 0.55); grain(ctx, w, h, 0.15);
|
||||
};
|
||||
|
||||
/* ---------- registry: id → draw(canvas, opts); scenes are drawn at t ---------- */
|
||||
PT.draw = function (id, cv, o = {}) {
|
||||
if (SCN[id]) { const sc = SCN[id]; const key = id + '|' + cv.width + 'x' + cv.height + '|' + (o.seed || 0) + '|' + (o.skyline ? 1 : 0) + (o.palm ? 1 : 0); PT._bases = PT._bases || new Map(); let B = PT._bases.get(key); if (!B) { B = sc.base(cv.width, cv.height, o); PT._bases.set(key, B); } sc.frame(cv.getContext('2d'), cv.width, cv.height, o.t ?? performance.now() / 1000, o, B); return true; }
|
||||
const f = PT[id]; if (typeof f !== 'function') return false; f(cv, o); return true;
|
||||
};
|
||||
PT.animated = (id) => !!SCN[id];
|
||||
@@ -37,7 +37,8 @@ function dayReplay(lat, lon, localHour = 15.5) {
|
||||
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 LAUNCH_T0 = NOW - (34 * 60 + 12) * 1000;
|
||||
GE.LAUNCH_T0 = LAUNCH_T0;
|
||||
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(); })();
|
||||
|
||||
@@ -123,11 +124,11 @@ add({
|
||||
});
|
||||
add({
|
||||
id: 'launch', short: 'Launch · SLC-40', fam: 'orbit', kind: 'launch', section: 'now', live: true, aspect: 1.5, ageS: 300, watchers: 33800,
|
||||
title: 'Rideshare launch from SLC-40 — countdown', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
|
||||
title: 'Twilight launch from SLC-40 — plume seen along 600 km of coast', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
|
||||
camera: { lat: 28.5585, lon: -80.5825, range: 0.00045, heading: 0.85, tilt: 1.28 }, labels3d: [[28.5618, -80.5772, 'SLC-40 · vehicle on pad', 0.11]], thumbCam: { lat: 28.25, lon: -80.95, range: 0.028, heading: 0.75, tilt: 1.0 }, layers: ['launches', 'flights', 'ais', 'tracks'], source: 'Range schedule (illustrative)',
|
||||
metric: () => [`<span class="num">T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}</span>`, 'Rideshare to SSO · trajectory NE'],
|
||||
metrics: () => [{ v: fmt.dur((LAUNCH_T0 - Date.now()) / 1000), u: '', l: 'Countdown', s: 'Range · 5 min' }, { v: 92, u: '%', l: 'Weather go', s: 'Forecast · 1 h' }, { v: 14, u: '', l: 'Vessels in hazard area', s: 'AIS · 40 s' }],
|
||||
brief: { lede: 'Vehicle is vertical on the pad. The hazard area offshore is still being cleared — 14 vessels remain inside. Trajectory heads northeast along the coast.', series: [{ label: 'Vessels in hazard area', unit: '', data: [41, 39, 36, 33, 31, 28, 24, 21, 19, 17, 15, 14], src: 'AIS · 15 min · 40 s', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['AIS', '40 s', 'live']] },
|
||||
metric: () => [`<span class="num">T+${fmt.dur((Date.now() - LAUNCH_T0) / 1000)}</span>`, 'Rideshare to SSO · 2nd stage coasting'],
|
||||
metrics: () => [{ v: fmt.dur((Date.now() - LAUNCH_T0) / 1000), u: '', l: 'Since liftoff', s: 'Range · live' }, { v: 212, u: 'km', l: '2nd stage altitude', s: 'Tracking · 10 s' }, { v: 418, u: '', l: 'Sky captures', s: 'Signed · 4 min' }],
|
||||
brief: { lede: 'The vehicle lifted off just after sunset. Its exhaust climbed into sunlight above the dark ground and spread into a glowing "jellyfish" seen from Georgia to the Keys. The second stage is coasting to its burn; the pad is being safed.', series: [{ label: 'Sky captures per minute', unit: '', data: [0, 2, 14, 61, 120, 96, 70, 44, 30, 22, 15, 11], src: 'Signed bundles · 1 min', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['Tracking camera (illustrative)', '10 s', 'live'], ['Signed sky captures', '4 min', '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,
|
||||
@@ -211,6 +212,21 @@ add({
|
||||
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']] },
|
||||
});
|
||||
|
||||
/* round two: situations behind the new feed cards (lite: camera, layers, brief) */
|
||||
const lite = (o) => add({ live: true, aspect: 1.25, section: 'now', watchers: 3000, metric: () => [o.m0 || '', o.m1 || ''], metrics: () => o.ms.map(([v, u, l, sx]) => ({ v, u, l, s: sx })), brief: { lede: o.lede, series: o.series || [], sources: o.sources || [] }, ...o });
|
||||
lite({ id: 'dfw', short: 'Squall line · DFW', fam: 'wx', kind: 'storm', title: 'Bowing squall line crossing Dallas–Fort Worth', place: 'Dallas–Fort Worth, Texas', lat: 32.82, lon: -97.0, camera: { lat: 32.62, lon: -97.12, range: 0.05, heading: 0.3, tilt: 0.9 }, layers: ['radar', 'lightning', 'flights', 'traffic'], source: 'Radar mosaic', watchers: 26100, ageS: 90,
|
||||
ms: [[62, 'dBZ', 'Peak reflectivity', 'Radar · 2 min'], [72, 'km/h', 'Line motion', 'Derived'], [1, '', 'Tornado warning', 'Warning feed · 4 min']], lede: 'A bowing line is racing east at 72 km/h. The apex is producing 100 km/h gusts and a brief tornado warning covers Arlington. Departures at DFW are on a ground stop.', series: [{ label: 'Peak dBZ', unit: '', data: [38, 41, 47, 52, 55, 58, 61, 62], src: 'Radar mosaic · 5 min', fmt: (v) => v }], sources: [['Radar mosaic (illustrative)', '2 min', 'live'], ['Lightning network', '30 s', 'live']] });
|
||||
lite({ id: 'gulf', short: 'Lightning · Gulf', fam: 'wx', kind: 'lightning', title: '4,812 lightning strikes in the last hour over the Gulf', place: 'Gulf of Mexico', lat: 26.5, lon: -89, camera: { lat: 24.5, lon: -89.5, range: 0.3, heading: 0, tilt: 0.5 }, layers: ['lightning', 'clouds', 'flights', 'ais'], source: 'Lightning network', watchers: 4100, ageS: 60,
|
||||
ms: [['4,812', '', 'Strikes / h', 'Network · 1 min'], [5, '', 'Active cells', 'Derived'], [31, '', 'Flights deviating', 'ADS-B · 2 min']], lede: 'Five clusters of deep convection are firing over warm Gulf water. Airliners are deviating around the two strongest cells.', sources: [['Lightning network (illustrative)', '1 min', 'live']] });
|
||||
lite({ id: 'shibuya', short: 'Shibuya crossing', fam: 'eyes', kind: 'eye', title: 'Shibuya crossing in the rain — public camera', place: 'Shibuya, Tokyo', lat: 35.6595, lon: 139.7005, camera: { lat: 35.6575, lon: 139.6985, range: 0.0009, heading: 0.6, tilt: 1.1 }, layers: ['cctv', 'transit', 'traffic', 'venues'], source: 'Public camera', watchers: 9400, ageS: 3,
|
||||
ms: [['~2,400', '', 'People per cycle', 'Camera count · 1 min'], [12, 'mm/h', 'Rain', 'Gauge · 10 min'], [46, 's', 'Signal cycle', 'Observed']], lede: 'A public street camera over the scramble. Counts are estimated on-device; no faces are kept.', sources: [['Public street camera (illustrative)', 'live', 'live']] });
|
||||
lite({ id: 'derby', short: 'Derby egress', fam: 'ground', kind: 'crowd', title: 'Derby egress: crowd density building at Gate C', place: 'Avellaneda, Buenos Aires', lat: -34.6705, lon: -58.3685, camera: { lat: -34.6735, lon: -58.3715, range: 0.0012, heading: 0.4, tilt: 1.0 }, layers: ['venues', 'transit', 'traffic', 'cctv'], source: 'Crowd sensing (illustrative)', watchers: 11800, ageS: 40,
|
||||
ms: [[4.6, 'p/m²', 'Peak density', 'Derived · 30 s'], ['41k', '', 'Leaving', 'Estimate'], [3, '', 'Gates open', 'Venue feed']], lede: 'Fans are leaving after the final whistle. Density outside Gate C has passed 4 people per square metre while Gates A and D are flowing freely. Stewards are being redirected.', series: [{ label: 'Density at Gate C', unit: 'p/m²', data: [1.1, 1.4, 2.0, 2.8, 3.5, 4.1, 4.6], src: 'Derived · 1 min', fmt: (v) => v.toFixed(1) }], sources: [['Crowd sensing (illustrative)', '30 s', 'live'], ['Signed captures', '3 min', 'live']] });
|
||||
lite({ id: 'sakurajima', short: 'Sakurajima cam', fam: 'haz', kind: 'volcano', title: 'Sakurajima ash column to 3.2 km — webcam', place: 'Kagoshima Bay, Japan', lat: 31.58, lon: 130.66, camera: { lat: 31.52, lon: 130.6, range: 0.012, heading: 0.5, tilt: 1.15 }, layers: ['volcanoes', 'flights', 'wind', 'cctv'], source: 'Volcano webcam', watchers: 5300, ageS: 120,
|
||||
ms: [[3.2, 'km', 'Ash column', 'Webcam · 2 min'], ['ESE', '', 'Drift', 'Wind model'], [0, '', 'Flights affected', 'ADS-B']], lede: 'A routine but tall explosion. Ash is drifting away from the city; the airport is open.', sources: [['Volcano webcam (illustrative)', '2 min', 'live'], ['Wind model', '1 h', 'live']] });
|
||||
lite({ id: 'lakefront', short: 'Lake Michigan gale', fam: 'wx', kind: 'gale', title: 'Gale on Lake Michigan — waves over the lakefront trail', place: 'Chicago lakefront', lat: 41.866, lon: -87.607, camera: { lat: 41.62, lon: -87.42, range: 0.035, heading: -0.35, tilt: 1.0 }, layers: ['cctv', 'signed', 'traffic'], source: 'Buoy + signed captures', watchers: 1900, ageS: 300, section: 'near',
|
||||
ms: [[3.4, 'm', 'Wave height', 'Buoy · 10 min'], [74, 'km/h', 'Gusts', 'Station · 5 min'], [2, '', 'Trail closures', 'City feed']], lede: 'North-northeast gales are piling waves onto the lakefront. The trail is closed between two harbours.', sources: [['Lake buoy (illustrative)', '10 min', 'live'], ['Signed captures', 'live', '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]));
|
||||
@@ -220,6 +236,7 @@ SIT.sections = [
|
||||
// dynamic cameras
|
||||
SIT.cameraFor = function (s) {
|
||||
if (s.id === 'starlink') { const g = GE.world.sun.gmst; const p = GE.satAt(GE.SATS.find((x) => x.kind === 5), GE.world.t, g); const [la, lo] = geo.ll(p); return { lat: la - 6, lon: lo - 8, range: 0.32, heading: 0.6, tilt: 0.85 }; }
|
||||
if (s.id === 'squawk' && GE.feed && GE.feed._sq) return s.camera;
|
||||
if (s.id === 'squawk') { const f = GE.FLIGHTS[GE.world.squawks[0]]; const p = GE.flightAt(f, GE.world.t) || { lat: 50, lon: -30 }; return { lat: p.lat - 4, lon: p.lon - 3, range: 0.3, heading: 0.3, tilt: 0.75 }; }
|
||||
if (s.id === 'iss') { const p = GE.satAt(GE.ISS, GE.world.t, GE.world.sun.gmst); const [la, lo] = geo.ll(p); return { lat: la - 9, lon: lo - 4, range: 0.48, heading: 0.35, tilt: 0.78 }; }
|
||||
return s.camera;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Rooms — every situation has one. Structured posts (Witness / Context / Question / Correction /
|
||||
Prediction) anchored in place and time, badges earned from evidence, community notes shown only when
|
||||
raters who usually disagree both find them helpful, and a Known / Disputed / Unknown synthesis whose
|
||||
every line cites a post or an observation. All people, posts and claims are fictional. */
|
||||
const RM = (GE.rooms = { byId: {} });
|
||||
const MIN_ = 60000, HR2 = 3600000;
|
||||
RM.KINDS = {
|
||||
witness: { name: 'Witness', icon: 'eye', tone: 'gold', hint: 'I saw or recorded this' },
|
||||
context: { name: 'Context', icon: 'layers', tone: 'sky', hint: 'Background, a map, a source' },
|
||||
question: { name: 'Question', icon: 'bubble', tone: 'white', hint: 'Floats to the top until answered' },
|
||||
correction: { name: 'Correction', icon: 'flag', tone: 'amber', hint: 'Something here is wrong' },
|
||||
prediction: { name: 'Prediction', icon: 'clock', tone: 'orbit', hint: 'What happens next — scored later' },
|
||||
};
|
||||
const P0 = GE.PEOPLE;
|
||||
const kmBetween = (a, b, c, d) => geo.dist(a, b, c, d) * EARTH_KM;
|
||||
RM.kmBetween = kmBetween;
|
||||
|
||||
/* ---------- room content ---------- */
|
||||
const CY = GE.CYCLONE.at(0);
|
||||
const ROOMS = {
|
||||
cyclone: {
|
||||
watching: 3204, rate: 4,
|
||||
src: { O1: 'Geostationary IR mosaic · 01:40Z', O2: 'Advisory + 14 ensemble tracks · 3 h', O3: 'AIS vessel positions · 40 s', S1: 'Sealed live stream, 6 segments · @nadia_basco' },
|
||||
posts: [
|
||||
{ id: 'cy1', kind: 'witness', by: 'nadia', ago: 4, live: true, text: 'Waves are coming over the seawall by the port every 30–40 seconds. The road behind it is ankle-deep. The wall itself is intact from what I can see — I\'m 200 m up the slope.', place: { lat: 20.4485, lon: 121.9705, label: 'Basco seawall' }, att: { paint: 'seawall', o: { palm: true, handheld: true, noOSD: true, seed: 52 }, tier: 'sealed-live', segs: 6, distKm: 0.2 }, reacts: { eye: 1240, heart: 212, alert: 48 }, replies: 31 },
|
||||
{ id: 'cy2', kind: 'question', by: 'rafa', ago: 6, text: 'Is the Magapit bridge into Aparri still open? Nobody here knows and the power is out.', place: { lat: 18.12, lon: 121.65, label: 'Magapit bridge' }, reacts: { eye: 96 }, replies: 2 },
|
||||
{ id: 'cy3', kind: 'context', by: 'lin', ago: 11, text: 'An eyewall replacement looks likely in the next 12 h. Expect the peak wind number to drop while the wind field gets wider. For Batanes that is not good news, so don\'t read a lower number as "weakening".', cites: ['O1', 'O2'], reacts: { heart: 340, eye: 520 }, replies: 18 },
|
||||
{ id: 'cy4', kind: 'witness', by: 'repost', ago: 9, text: 'VIDEO: Basco port pier has COLLAPSED!! Share before it\'s deleted', att: { paint: 'harbour', o: { seed: 19, cam: 'CAM 03', place: 'PORT' }, tier: 'unverified', distKm: null }, reacts: { alert: 860 }, replies: 77,
|
||||
note: { text: 'This clip is from a 2019 storm at a different port. Reverse-image search finds 3 earlier copies. The pier is visible and intact in tonight\'s sealed live stream from Basco [P1].', helpfulA: 0.81, helpfulB: 0.74, ratersA: 118, ratersB: 94, cites: ['P1'] } },
|
||||
{ id: 'cy5', kind: 'prediction', by: 'joon', ago: 14, text: 'Kaohsiung will close the port to outbound traffic by 06:00 local. 11 ships have already left the anchorage and are heading south of the track.', place: { lat: 22.61, lon: 120.27, label: 'Kaohsiung pier 70' }, att: { paint: 'harbour', o: { seed: 8, cam: 'HBR-07', place: 'KAOHSIUNG PIER 70' }, tier: 'eye', distKm: null }, cites: ['O3'], reacts: { eye: 210, heart: 41 }, replies: 6, resolves: '06:00 local' },
|
||||
{ id: 'cy6', kind: 'witness', by: 'rafa', ago: 22, text: 'Power out across Aparri centro since 01:10. The hospital is on generators. Rain is sideways.', place: { lat: 18.357, lon: 121.637, label: 'Aparri centro' }, att: { paint: 'blackout', o: { seed: 91 }, tier: 'sealed', distKm: 0.4 }, reacts: { heart: 190, eye: 300 }, replies: 9 },
|
||||
{ id: 'cy7', kind: 'correction', by: 'ais', ago: 18, text: 'Correction to an earlier post: there are 2 ships holding off Batanes, not 6. The other 4 are at anchor in the lee of Sabtang and are fine.', cites: ['O3'], place: { lat: 20.34, lon: 121.85, label: 'Sabtang lee' }, reacts: { heart: 88 }, replies: 3 },
|
||||
],
|
||||
synth: {
|
||||
known: [['Waves are overtopping the Basco seawall near the port; the wall itself is intact as of a few minutes ago.', ['P1', 'S1']], ['Eyewall cloud tops are colder than −80 °C with frequent lightning.', ['O1']], ['Aparri centro has lost power; the hospital is on generators.', ['P6']]],
|
||||
disputed: [['A viral "collapsed pier" video — a community note shows it predates this storm.', ['P4', 'N1']], ['Whether Halcyon is weakening: peak wind estimates dip while the wind field broadens.', ['P3', 'O2']]],
|
||||
unknown: [['Whether the Magapit bridge into Aparri is open — asked 6 min ago, no evidence yet.', ['P2']], ['Landfall point: guidance still spreads ±150 km at 36 h.', ['O2']]],
|
||||
},
|
||||
},
|
||||
quake: {
|
||||
watching: 1880, rate: 2,
|
||||
src: { O1: 'Seismic catalog · revised 12 min ago', O2: 'Tide gauges · 3 min', O3: 'Rail status boards · 9 min' },
|
||||
posts: [
|
||||
{ id: 'qk1', kind: 'question', by: 'aiko', ago: 48, text: 'Are trains running north of Sendai yet? Stuck at the station with my kids.', place: { lat: 38.26, lon: 140.88, label: 'Sendai station' }, answered: 'qk2', reacts: { eye: 140 }, replies: 5 },
|
||||
{ id: 'qk2', kind: 'context', by: 'tetsu', ago: 41, text: 'Northbound service resumed at 02:41 after track inspection. Expect 20–30 min delays through Fukushima.', cites: ['O3'], reacts: { heart: 220 }, replies: 4 },
|
||||
{ id: 'qk3', kind: 'witness', by: 'aiko', ago: 300, text: 'Long rolling shake, maybe 40 seconds. Shelves came down in the store and everyone came out onto the street. Nobody hurt here.', place: { lat: 38.262, lon: 140.871, label: 'Aoba-ku' }, att: { paint: 'crossing', o: { seed: 33, cam: 'PHONE', rec: false }, tier: 'sealed', distKm: 162 }, reacts: { heart: 410, eye: 700 }, replies: 22 },
|
||||
{ id: 'qk4', kind: 'correction', by: 'geo', ago: 280, text: 'The depth is 32 km in the revised solution, not the 10 km in early reposts. That matters for how far the shaking reached.', cites: ['O1'], reacts: { heart: 160 }, replies: 7 },
|
||||
{ id: 'qk5', kind: 'witness', by: 'repost', ago: 310, text: 'TSUNAMI 3 METERS hitting the coast right now!!!', reacts: { alert: 1900 }, replies: 140,
|
||||
note: { text: 'Tide gauges along the coast recorded at most 20 cm [O2]. No tsunami warning was issued. The video attached to the original claim is from 2011.', helpfulA: 0.88, helpfulB: 0.79, ratersA: 302, ratersB: 241, cites: ['O2'] } },
|
||||
{ id: 'qk6', kind: 'prediction', by: 'geo', ago: 200, text: 'Chance of an M5+ aftershock in the next 72 h is about 1 in 4 for a sequence like this. Not a reason to panic, a reason to secure shelves.', cites: ['O1'], reacts: { heart: 98 }, replies: 11, resolves: 'in 72 h' },
|
||||
],
|
||||
synth: {
|
||||
known: [['An M 6.4 thrust event at 32 km depth off the Honshu coast.', ['O1', 'P4']], ['Tide gauges recorded at most 20 cm; no tsunami.', ['O2', 'N1']], ['Northbound trains resumed at 02:41 with delays.', ['P2', 'O3']]],
|
||||
disputed: [['Early reposts claimed a 3 m tsunami — rated false by raters across perspectives.', ['P5', 'N1']]],
|
||||
unknown: [['Damage inland of Fukushima — the only sealed capture so far is from Sendai.', ['P3']], ['Whether a larger aftershock follows (≈ 1 in 4 within 72 h).', ['P6']]],
|
||||
},
|
||||
},
|
||||
squawk: {
|
||||
watching: 2410, rate: 3,
|
||||
src: { O1: 'ADS-B · 1 Hz', O2: 'Diversion airport board · 2 min' },
|
||||
posts: [
|
||||
{ id: 'sq1', kind: 'witness', by: 'seat', ago: 7, text: 'On board. Masks dropped about 20 minutes ago. Crew are calm, we\'re descending and they said we\'re landing soon. Everyone is OK.', att: { paint: 'cabin', o: { seed: 81 }, tier: 'sealed', distKm: 0 }, reacts: { heart: 1820, eye: 2100 }, replies: 64 },
|
||||
{ id: 'sq2', kind: 'context', by: 'tomas', ago: 12, text: 'Squawk 7700, a turn toward the nearest long runway and a steady ~2,400 ft/min descent to FL100 is what a pressurisation problem looks like. It is a well-practised procedure.', cites: ['O1'], reacts: { heart: 960 }, replies: 21 },
|
||||
{ id: 'sq3', kind: 'question', by: 'sky', ago: 5, text: 'Does anyone know which runway they\'ll use? Wind there is strong from the east.', reacts: { eye: 44 }, replies: 1 },
|
||||
{ id: 'sq4', kind: 'prediction', by: 'tomas', ago: 9, text: 'They\'ll be on the ground in about 34 minutes.', cites: ['O1', 'O2'], reacts: { heart: 120 }, replies: 2, resolves: 'at landing' },
|
||||
],
|
||||
synth: {
|
||||
known: [['The aircraft squawked 7700, turned toward the diversion airport and is descending.', ['O1']], ['A passenger on board reports masks deployed and the crew calm.', ['P1']]],
|
||||
disputed: [],
|
||||
unknown: [['The cause — the descent profile fits a pressurisation problem, unconfirmed.', ['P2']], ['Landing runway and time (est. ~34 min).', ['P3', 'P4']]],
|
||||
},
|
||||
},
|
||||
fires: {
|
||||
watching: 1460, rate: 2,
|
||||
src: { O1: 'VIIRS 375 m hotspots · 38 min', O2: 'Wind model · 1 h' },
|
||||
posts: [
|
||||
{ id: 'fi1', kind: 'witness', by: 'ines', ago: 26, text: 'The ridge above the N2 is burning along about two kilometres. Ash is falling on the road. Traffic is still moving south.', place: { lat: 40.13, lon: -8.05, label: 'N2 at the ridge' }, att: { paint: 'firewit', o: { seed: 71 }, tier: 'sealed', distKm: 1.8 }, reacts: { heart: 260, eye: 900 }, replies: 18 },
|
||||
{ id: 'fi2', kind: 'context', by: 'bomb', ago: 20, text: 'Front is moving west at about 1.2 km/h. The N2 will close between the two junctions within the hour. Please don\'t stop on the road to film.', cites: ['O1', 'O2'], reacts: { heart: 700 }, replies: 12 },
|
||||
{ id: 'fi3', kind: 'question', by: 'ines', ago: 8, text: 'Is the IC8 open toward Coimbra?', reacts: { eye: 60 }, replies: 0 },
|
||||
],
|
||||
synth: { known: [['A fire front about 2 km long is burning above the N2, moving west ~1.2 km/h.', ['P1', 'P2', 'O1']]], disputed: [], unknown: [['Whether the IC8 toward Coimbra is open.', ['P3']]] },
|
||||
},
|
||||
};
|
||||
// light rooms for the rest: a context post from the brief + an open question
|
||||
function liteRoom(s) {
|
||||
const by = ['kora', 'sky', 'geo', 'ais', 'hydro'][s.id.length % 5];
|
||||
return { watching: Math.round((s.watchers || 2000) * 0.06) + 40, rate: 1, src: Object.fromEntries((s.brief?.sources || []).slice(0, 3).map(([n, a], i) => ['O' + (i + 1), `${n} · ${a}`])),
|
||||
posts: [{ id: s.id + '-1', kind: 'context', by, ago: 12, text: s.brief?.lede || s.title, cites: ['O1'], reacts: { heart: 40, eye: 120 }, replies: 3 }, { id: s.id + '-2', kind: 'question', by: 'kora', ago: 5, text: 'Anyone close enough to send a sealed capture?', reacts: { eye: 20 }, replies: 0 }],
|
||||
synth: { known: [[s.brief?.lede?.split('. ')[0] + '.', ['O1', 'P1']]], disputed: [], unknown: [['No witness captures yet.', ['P2']]] } };
|
||||
}
|
||||
|
||||
/* ---------- derived: badges, evidence score, ordering ---------- */
|
||||
RM.badges = function (room, p) {
|
||||
const s = GE.SIT.byId[room.sit]; const who = P0[p.by] || {}; const out = [];
|
||||
const sealed = p.att && /^sealed/.test(p.att.tier);
|
||||
if (sealed && p.att.distKm != null && p.att.distKm <= 5) out.push(['there', `Was there · ${p.att.distKm < 1 ? Math.round(p.att.distKm * 1000) + ' m' : p.att.distKm.toFixed(1) + ' km'}`]);
|
||||
if (sealed) out.push(['sealed', p.att.tier === 'sealed-live' ? 'Sealed live' : 'Sealed']);
|
||||
if (who.home && s && kmBetween(who.home[0], who.home[1], p.place?.lat ?? s.lat, p.place?.lon ?? s.lon) < 120) out.push(['local', 'Local']);
|
||||
if (who.domain) out.push(['domain', who.domain]);
|
||||
if (p.att && p.att.tier === 'unverified') out.push(['unv', 'Unverified upload']);
|
||||
if (p.by === 'you' && GE.ME && s && kmBetween(GE.ME.lat, GE.ME.lon, s.lat, s.lon) < 120 && !out.some((b) => b[0] === 'local')) out.push(['local', 'Local']);
|
||||
return out;
|
||||
};
|
||||
RM.score = function (room, p) { let sc = 0; for (const [k] of RM.badges(room, p)) sc += { there: 4, sealed: 3, domain: 2, local: 1, unv: -2 }[k] || 0; if (p.att?.tier === 'eye') sc += 1; if (p.note) sc -= 3; return sc; };
|
||||
RM.ordered = function (room, mode = 'evidence') {
|
||||
const ps = room.posts.slice();
|
||||
const openQ = (p) => p.kind === 'question' && !p.answered;
|
||||
if (mode === 'latest') return ps.sort((a, b) => b.at - a.at);
|
||||
if (mode === 'questions') return ps.filter((p) => p.kind === 'question').sort((a, b) => (openQ(b) - openQ(a)) || b.at - a.at);
|
||||
return ps.sort((a, b) => (openQ(b) - openQ(a)) || RM.score(room, b) - RM.score(room, a) || b.at - a.at);
|
||||
};
|
||||
RM.pid = (room, p) => 'P' + (room.posts.indexOf(p) + 1);
|
||||
RM.byPid = (room, pid) => room.posts[+pid.slice(1) - 1];
|
||||
|
||||
/* ---------- ledger per room (real SHA-256 chain) ---------- */
|
||||
RM.append = async function (room, type, actor, payload) {
|
||||
const L = room.ledger; const prev = L.length ? L[L.length - 1].hash : '0'.repeat(64);
|
||||
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;
|
||||
};
|
||||
|
||||
/* ---------- build ---------- */
|
||||
RM.get = function (sitId) {
|
||||
if (RM.byId[sitId]) return RM.byId[sitId];
|
||||
const s = GE.SIT.byId[sitId]; if (!s) return null;
|
||||
const R0 = ROOMS[sitId] || liteRoom(s);
|
||||
const room = { sit: sitId, ...R0, ledger: [], posts: R0.posts.map((p) => ({ ...p, at: NOW - (p.ago || 1) * MIN_ })) };
|
||||
for (const p of room.posts) if (p.att && p.att.paint === 'seawall' && p.live) p.att.o.cam = '@' + P0[p.by].h;
|
||||
RM.byId[sitId] = room;
|
||||
(async () => { for (const p of room.posts) await RM.append(room, 'post.' + p.kind, P0[p.by]?.h || p.by, { id: p.id, text: p.text, att: p.att ? { tier: p.att.tier } : null, place: p.place || null }); })();
|
||||
return room;
|
||||
};
|
||||
RM.count = (sitId) => { const r = ROOMS[sitId]; return r ? r.posts.length : 2; };
|
||||
RM.watching = (sitId) => (RM.byId[sitId]?.watching) ?? ROOMS[sitId]?.watching ?? Math.round((GE.SIT.byId[sitId]?.watchers || 2000) * 0.06) + 40;
|
||||
RM.sealedCount = (sitId) => { const r = RM.byId[sitId] || ROOMS[sitId]; return r ? r.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length : 0; };
|
||||
RM.topClaim = function (sitId) {
|
||||
const r = ROOMS[sitId]; if (!r) return null;
|
||||
const open = r.posts.find((p) => p.kind === 'question' && !p.answered);
|
||||
const k = r.synth.known[0], d = r.synth.disputed[0];
|
||||
return { q: open?.text, known: k?.[0], disputed: d?.[0], sealed: r.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length };
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/* Feed model — mixed archetypes across the four altitude bands, ordered for diversity.
|
||||
Bands answer "where is the sensor": Orbit, Sky, Ground, and Inside (a person). */
|
||||
const FD = (GE.feed = {});
|
||||
GE.BANDS = {
|
||||
orbit: { name: 'Orbit', c: '#B79CFF', alt: '400–36,000 km', sensor: 'satellites · ISS · launches' },
|
||||
sky: { name: 'Sky', c: '#8CD4FF', alt: '0–15 km', sensor: 'aircraft · radar · lightning' },
|
||||
ground: { name: 'Ground', c: '#FFA53D', alt: '0 m', sensor: 'cameras · seismometers · crowds' },
|
||||
inside: { name: 'Inside', c: '#F2CF7A', alt: 'you', sensor: 'witnesses · broadcasts · rooms' },
|
||||
};
|
||||
GE.ARCH = { sensor: 'Sensor', eye: 'Eye', track: 'Track', number: 'Number', witness: 'Witness', room: 'Room' };
|
||||
const MIN3 = 60000;
|
||||
FD.fmtDist = (km) => (km < 1 ? Math.round(km * 1000) + ' m' : km < 100 ? km.toFixed(km < 10 ? 1 : 0) + ' km' : fmt.n(Math.round(km)) + ' km');
|
||||
FD.distFromMe = (lat, lon) => geo.dist(GE.ME.lat, GE.ME.lon, lat, lon) * EARTH_KM;
|
||||
const llz = (lat, lon) => `${Math.abs(lat).toFixed(2)}${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}${lon >= 0 ? 'E' : 'W'}`;
|
||||
const hmsZ = () => { const d = new Date(); return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}Z`; };
|
||||
FD.hmsZ = hmsZ; FD.llz = llz;
|
||||
|
||||
/* the diverting flight, computed from the synthetic traffic so card, room and globe agree */
|
||||
FD.squawkTrack = function () {
|
||||
if (FD._sq) return FD._sq;
|
||||
const f = GE.FLIGHTS[GE.world.squawks[0]]; const pts = [];
|
||||
for (let k = -90; k <= 0; k += 3) { const p = GE.flightAt(f, GE.world.t + k * 60); if (p) pts.push([p.lat, p.lon]); }
|
||||
let ok = pts.length > 12;
|
||||
if (!ok) { pts.length = 0; for (let k = 0; k <= 30; k++) pts.push(k < 18 ? [55 + k * 0.12, -10 - k * 0.9] : [57.1 + (k - 18) * 0.25, -26.2 - (k - 18) * 0.35]); }
|
||||
const cur = pts[pts.length - 1]; let div = null, bd = 1e9;
|
||||
for (const a of GE.AP) { if (a.w < 8) continue; const d = geo.dist(cur[0], cur[1], a.lat, a.lon); if (d < bd && d > 0.02) { bd = d; div = a; } }
|
||||
if (!ok) div = GE.apByIata.RKV || div;
|
||||
// bend the last third toward the diversion field
|
||||
const k0 = Math.floor(pts.length * 0.62); const bend = pts.slice(0, k0 + 1); const a = pts[k0];
|
||||
for (let i = 1; i <= pts.length - 1 - k0; i++) { const t = (i / (pts.length - 1 - k0)) * 0.45; bend.push([lerp(a[0], div.lat, t), lerp(a[1], div.lon, t)]); }
|
||||
const alt = [37000, 37000, 36000, 31000, 27000, 24000];
|
||||
FD._sq = { pts: bend, sqAt: k0, div: { lat: div.lat, lon: div.lon, iata: div.iata, city: div.city }, alt, cur: bend[bend.length - 1] };
|
||||
// put the same track on the globe: white before the declaration, red after, the aircraft at its head
|
||||
const W = GE.world; const C2 = FD._sq.cur; const alt3 = (i) => 10.5 - Math.max(0, i - k0) * 0.25;
|
||||
W.extraLines.push({ pts: bend.slice(0, k0 + 1).map(([la, lo], i) => geo.vec(la, lo, alt3(i))), col: [0.85, 0.9, 1, 0.55] }, { pts: bend.slice(k0).map(([la, lo], i) => geo.vec(la, lo, alt3(i + k0))), col: [1, 0.25, 0.2, 0.95] }, { pts: [geo.vec(C2[0], C2[1], 7), geo.vec(div.lat, div.lon, 0)], col: [1, 0.45, 0.4, 0.5], dashed: 1 });
|
||||
(W.extraSprites = W.extraSprites || []).push({ v: geo.vec(C2[0], C2[1], 7), col: [1, 0.23, 0.19, 1], size: 30 });
|
||||
const s = GE.SIT.byId.squawk; if (s) { s.lat = C2[0]; s.lon = C2[1]; s.camera = { lat: C2[0] - 4, lon: C2[1] - 3, range: 0.14, heading: 0.35, tilt: 0.85 }; }
|
||||
return FD._sq;
|
||||
};
|
||||
|
||||
/* ---------- items ---------- */
|
||||
FD.items = function () {
|
||||
if (FD._items) return FD._items;
|
||||
const C = GE.CYCLONE.at(0); const sq = FD.squawkTrack(); const iss = GE.SIT.issPass(Date.now());
|
||||
const it = [
|
||||
{ id: 'basco', arch: 'witness', band: 'inside', sit: 'cyclone', post: 'cy1', wide: true, prio: 100, live: true, by: 'nadia', paint: 'seawall', po: { palm: true, handheld: true, noOSD: true, seed: 52 }, aspect: 0.62,
|
||||
title: 'Waves over the Basco seawall — live from the port', place: 'Basco, Batanes', lat: 20.4485, lon: 121.9705, ago: 14, cam: { lat: 18.6, lon: 123.4, range: 0.18, heading: -0.55, tilt: 0.62 },
|
||||
tele: () => `WITNESS · SEALED LIVE · ±4 M · ${fmt.n(Math.round(RM.kmBetween(20.4485, 121.9705, C.lat, C.lon)))} KM FROM EYE` },
|
||||
{ id: 'ir', arch: 'sensor', band: 'orbit', sit: 'cyclone', prio: 95, paint: 'ir', aspect: 1.25, title: 'Typhoon Halcyon: −83 °C cloud tops and lightning in the eyewall', place: 'Philippine Sea', lat: C.lat, lon: C.lon, ago: 2,
|
||||
tele: () => `ORBIT · GEO IR · 35,786 KM · ${llz(C.lat, C.lon)}` },
|
||||
{ id: 'cabin', arch: 'witness', band: 'inside', sit: 'squawk', post: 'sq1', prio: 92, by: 'seat', paint: 'cabin', aspect: 1.3, title: '“Masks dropped. Crew calm. We’re descending.” — on board the 7700', place: 'Over the North Atlantic', lat: sq.cur[0], lon: sq.cur[1], ago: 7,
|
||||
tele: () => 'WITNESS · SEALED · ON BOARD · FL240' },
|
||||
{ id: 'harbour', arch: 'eye', band: 'ground', sit: 'cyclone', post: 'cy5', prio: 90, live: true, paint: 'harbour', po: { seed: 8, cam: 'HBR-07', place: 'KAOHSIUNG · PIER 70' }, aspect: 1.0, title: 'Kaohsiung harbour cam: ships leave ahead of Halcyon', place: 'Kaohsiung, Taiwan', lat: 22.61, lon: 120.27, ago: 0, cam: { lat: 21.2, lon: 121.6, range: 0.2, heading: -0.7, tilt: 0.6 },
|
||||
tele: () => `GROUND · CAM HBR-07 · ${llz(22.61, 120.27)} · ${hmsZ()}` },
|
||||
{ id: 'squawk', arch: 'track', band: 'sky', sit: 'squawk', prio: 89, live: true, paint: 'track', po: { track: sq }, aspect: 1.15, title: `Squawk 7700 — diverting to ${sq.div.iata} ${sq.div.city ? '(' + sq.div.city + ')' : ''}`, place: 'Over the North Atlantic', lat: sq.cur[0], lon: sq.cur[1], ago: 3,
|
||||
readout: 'SQ 7700 · FL240 ↓', tele: () => 'SKY · ADS-B · 2,400 FPM ↓ · 412 KT', cta: ['airplane', 'Follow the track'] },
|
||||
{ id: 'room-cy', arch: 'room', band: 'inside', sit: 'cyclone', prio: 88, wide: true, title: 'Typhoon Halcyon', place: 'Batanes · Babuyan · Luzon Strait', lat: 20.45, lon: 121.97, ago: 1, ev: [['seawall', { palm: true, noOSD: true, seed: 52 }], ['harbour', { seed: 8, cam: 'HBR-07' }], ['blackout', { seed: 91 }], ['ir', {}]] },
|
||||
{ id: 'radar', arch: 'sensor', band: 'sky', sit: 'dfw', prio: 86, paint: 'radar', aspect: 1.2, title: 'Bowing squall line crossing Dallas–Fort Worth — tornado warning', place: 'Dallas–Fort Worth', lat: 32.82, lon: -97.0, ago: 2,
|
||||
tele: () => 'SKY · RADAR 0.5° · 62 DBZ · 72 KM/H E' },
|
||||
{ id: 'crossing', arch: 'eye', band: 'ground', sit: 'shibuya', prio: 84, live: true, paint: 'crossing', po: { seed: 31, cam: 'CAM 0412', place: 'SHIBUYA SCRAMBLE' }, aspect: 1.25, title: 'Shibuya crossing in the rain: ~2,400 people every cycle', place: 'Shibuya, Tokyo', lat: 35.6595, lon: 139.7005, ago: 0,
|
||||
tele: () => `GROUND · CAM 0412 · ${llz(35.6595, 139.7005)} · ${hmsZ()}` },
|
||||
{ id: 'shake', arch: 'sensor', band: 'ground', sit: 'quake', prio: 83, paint: 'shake', aspect: 1.15, title: 'M 6.4 off Honshu: strongest shaking on the Fukushima coast', place: 'Off eastern Honshu', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon, ago: 324,
|
||||
tele: () => 'GROUND · 46 STATIONS · MMI VI · 32 KM DEEP' },
|
||||
{ id: 'firesat', arch: 'sensor', band: 'orbit', sit: 'fires', prio: 80, paint: 'firesat', aspect: 1.1, title: 'Central Portugal: a 60 km smoke plume drifts toward the coast', place: 'Pampilhosa da Serra', lat: 40.05, lon: -7.95, ago: 38,
|
||||
tele: () => 'ORBIT · 375 M · FRP 840 MW · WIND ESE 31' },
|
||||
{ id: 'isscam', arch: 'eye', band: 'orbit', sit: 'iss', prio: 79, live: true, paint: 'isscam', aspect: 1.3, title: 'From the station’s external camera: the Nile at night', place: '418 km over Egypt', lat: 27.1, lon: 31.2, ago: 0,
|
||||
tele: () => 'ORBIT · ISS · 418 KM · 7.66 KM/S · 27.1N 31.2E' },
|
||||
{ id: 'firewit', arch: 'witness', band: 'inside', sit: 'fires', post: 'fi1', prio: 78, by: 'ines', paint: 'firewit', aspect: 1.25, title: 'The ridge above the N2 is burning — 1.8 km from the front', place: 'N2, central Portugal', lat: 40.13, lon: -8.05, ago: 26,
|
||||
tele: () => 'WITNESS · SEALED · ±6 M · 1.8 KM FROM FRONT' },
|
||||
{ id: 'lightning', arch: 'number', band: 'sky', sit: 'gulf', prio: 76, paint: 'strikes', aspect: 1.05, title: 'Lightning over the Gulf of Mexico', place: 'Gulf of Mexico', lat: 26.5, lon: -89, ago: 1,
|
||||
big: '4,812', unit: 'strikes', label: 'in the last hour over the Gulf', spark: [1200, 1500, 1900, 2600, 3100, 3800, 4300, 4812], tele: () => 'SKY · LIGHTNING NETWORK · 5 CELLS' },
|
||||
{ id: 'stadium', arch: 'sensor', band: 'ground', sit: 'derby', prio: 75, live: true, paint: 'stadium', aspect: 1.1, title: 'Derby egress: 4.6 people/m² building at Gate C', place: 'Avellaneda, Buenos Aires', lat: -34.6705, lon: -58.3685, ago: 1,
|
||||
tele: () => 'GROUND · CROWD SENSING · GATE C · 4.6 P/M²' },
|
||||
{ id: 'aurora', arch: 'witness', band: 'inside', sit: 'aurora', prio: 74, by: 'ola', paint: 'aurora', aspect: 1.2, title: 'Corona overhead in Tromsø, sealed 18 minutes ago', place: 'Tromsø, Norway', lat: 69.65, lon: 18.96, ago: 18,
|
||||
tele: () => 'WITNESS · SEALED · ±5 M · KP 6' },
|
||||
{ id: 'skychart', arch: 'track', band: 'orbit', sit: 'iss', prio: 72, paint: 'skychart', po: { at: iss.start, max: Math.round(iss.max) }, aspect: 1.0, title: `The ISS crosses your sky at ${fmt.hm(iss.maxAt)} UTC`, place: `Over ${GE.ME.name}`, lat: GE.ME.lat, lon: GE.ME.lon, ago: 0,
|
||||
readout: `MAX ${Math.round(iss.max)}° · ${Math.round((iss.end - iss.start) / 60000)} MIN`, tele: () => 'ORBIT · ISS PASS · TLE · COMPUTED', cta: ['bell', 'Remind me'] },
|
||||
{ id: 'plume', arch: 'track', band: 'orbit', sit: 'launch', prio: 71, paint: 'plume', aspect: 1.3, title: 'Twilight launch: the plume was seen along 600 km of coast', place: 'Cape Canaveral', lat: 28.5618, lon: -80.5772, ago: 34,
|
||||
readout: () => `T+${fmt.dur((Date.now() - GE.LAUNCH_T0) / 1000)}`, tele: () => 'ORBIT · TRACKING CAM · 2ND STAGE 212 KM' },
|
||||
{ id: 'room-qk', arch: 'room', band: 'inside', sit: 'quake', prio: 70, title: 'M 6.4 off Honshu', place: 'Fukushima · Miyagi · Ibaraki', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon, ago: 3, ev: [['shake', {}], ['crossing', { seed: 33, cam: 'PHONE', rec: false }]] },
|
||||
{ id: 'gnss', arch: 'number', band: 'sky', sit: 'gnss', prio: 68, paint: 'gnss', aspect: 1.05, title: 'GPS interference over the Baltic', place: 'Baltic Sea', lat: 56.8, lon: 21.5, ago: 10,
|
||||
big: '340', unit: 'aircraft', label: 'lost GPS integrity in the last hour', spark: [120, 160, 150, 210, 260, 240, 300, 340], tele: () => 'SKY · ADS-B NIC/NACp · 10 MIN' },
|
||||
{ id: 'heli', arch: 'track', band: 'sky', sit: 'novosel', prio: 66, live: true, paint: 'heli', po: { laps: 14 }, aspect: 1.1, title: 'HAWK 21 is flying its 14th circuit at Fort Novosel', place: 'Cairns Army Airfield, Alabama', lat: 31.276, lon: -85.713, ago: 0, ride: true,
|
||||
readout: 'CIRCUIT 14 · 500 FT', tele: () => 'SKY · ADS-B/MLAT · 72 KT · 500 FT AGL', cta: ['helicopter', 'Ride along'] },
|
||||
{ id: 'koshi', arch: 'room', band: 'inside', sit: 'koshi', recon: 'koshi', prio: 64, wide: true, paint: 'globe', aspect: 0.56, title: 'Bhote Koshi outburst flood — 4D reconstruction', place: 'Rasuwa, Nepal · 26 Aug 2026', lat: 28.215, lon: 85.36, ago: 120 },
|
||||
{ id: 'sar', arch: 'sensor', band: 'orbit', sit: 'suez', prio: 62, paint: 'sar', aspect: 1.15, title: 'Radar satellite pass: 48 ships waiting off Suez', place: 'Gulf of Suez', lat: 29.86, lon: 32.55, ago: 47,
|
||||
tele: () => 'ORBIT · SAR C-BAND · 10 M · DESC PASS' },
|
||||
{ id: 'volcano', arch: 'eye', band: 'ground', sit: 'sakurajima', prio: 60, live: true, paint: 'volcano', po: { cam: 'VOLCANO CAM 2', place: 'KAGOSHIMA BAY' }, aspect: 1.05, title: 'Sakurajima: ash column to 3.2 km, drifting away from the city', place: 'Kagoshima, Japan', lat: 31.58, lon: 130.66, ago: 2,
|
||||
tele: () => `GROUND · VOLCANO CAM 2 · ${llz(31.58, 130.66)}` },
|
||||
{ id: 'blackout', arch: 'witness', band: 'inside', sit: 'cyclone', post: 'cy6', prio: 58, by: 'rafa', paint: 'blackout', aspect: 1.15, title: 'Aparri is dark — the hospital is running on generators', place: 'Aparri, Cagayan', lat: 18.357, lon: 121.637, ago: 22,
|
||||
tele: () => 'WITNESS · SEALED · ±8 M · LOCAL' },
|
||||
];
|
||||
for (const x of it) { x.at = NOW - x.ago * MIN3; x.dist = FD.distFromMe(x.lat, x.lon); }
|
||||
return (FD._items = it);
|
||||
};
|
||||
FD.user = []; // your own posts land here (newest first)
|
||||
|
||||
/* diversity-aware ordering: never two of the same archetype or band side by side when avoidable */
|
||||
FD.order = function (items) {
|
||||
const rest = items.slice().sort((a, b) => b.prio - a.prio); const out = [];
|
||||
while (rest.length) {
|
||||
const prev = out[out.length - 1], prev2 = out[out.length - 2];
|
||||
const fits = (x, strict) => !prev || ((x.arch !== prev.arch || !strict) && x.band !== prev.band && (!strict || !prev2 || x.arch !== prev2.arch));
|
||||
let i = rest.findIndex((x) => fits(x, true)); if (i < 0) i = rest.findIndex((x) => fits(x, false)); if (i < 0) i = 0;
|
||||
out.push(rest.splice(i, 1)[0]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
FD.list = function (band = 'all') {
|
||||
const all = [...FD.user, ...FD.items()];
|
||||
const f = band === 'all' ? all : band === 'near' ? all.filter((x) => x.dist < 2500) : all.filter((x) => x.band === band);
|
||||
const user = f.filter((x) => x.mine); const ordered = FD.order(f.filter((x) => !x.mine));
|
||||
return [...user, ...ordered];
|
||||
};
|
||||
FD.counts = function () { const c = { all: 0, orbit: 0, sky: 0, ground: 0, inside: 0 }; for (const x of [...FD.user, ...FD.items()]) { c.all++; c[x.band]++; } return c; };
|
||||
@@ -67,11 +67,17 @@ APP.updateChrome = function () {
|
||||
|
||||
/* ---------- 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>`;
|
||||
// the tab-bar accessory follows a person, not a number: the live witness you follow (or you, when live)
|
||||
const me = GE.capture?.live; let ttl, sub, open;
|
||||
if (me && me.on) { ttl = `You’re live · ${fmt.dur((Date.now() - me.t0) / 1000).slice(3)}`; sub = `Sealed live · ${me.segs.length} segments · ${fmt.n(me.viewers)} watching`; open = () => APP.setTab('capture'); }
|
||||
else { const r = GE.rooms.byId.cyclone; const n = r ? r.watching : 3204; ttl = '@nadia_basco · Basco seawall'; sub = `Sealed live · Typhoon Halcyon room · ${fmt.n(n)} watching`; open = () => { const it = GE.feed.items().find((x) => x.id === 'basco'); GE.now._item = it; GE.sheet.open('cyclone', { post: 'cy1' }); }; }
|
||||
APP._accOpen = open;
|
||||
const html = `<span class="accdot"><span class="dot pulse"></span></span><span class="txt"><div class="ttl">${esc(ttl)}</div><div class="sub num">${esc(sub)}</div></span><canvas class="accv" width="120" height="68"></canvas><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
|
||||
const a = $('#acc'); if (a._k !== ttl + sub) { a.innerHTML = html; a._k = ttl + sub; }
|
||||
const m = $('#accMini'); if (m._k !== ttl + sub) { m.innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(ttl)}</div><div class="sub num">${esc(sub)}</div></span>`; m._k = ttl + sub; }
|
||||
};
|
||||
APP.accFrame = function () { const cv = $('#acc .accv'); if (!cv || $('#accHost').classList.contains('hidden')) return; const me = GE.capture?.live; if (me && me.on) GE.paint.draw('seawall', cv, { skyline: true, noOSD: true, seed: 53, t: performance.now() / 1000 }); else GE.paint.draw('seawall', cv, { palm: true, noOSD: true, seed: 52, t: performance.now() / 1000 }); };
|
||||
setInterval(() => { try { APP.accFrame(); } catch {} }, 200);
|
||||
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)]);
|
||||
@@ -92,6 +98,8 @@ APP.updateLabels = function (view) {
|
||||
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 }) });
|
||||
}
|
||||
const showEyes = (APP.tab === 'globe' || APP.isWide()) && !APP.intro && APP.mode !== 'ride' && APP.mode !== 'workspace';
|
||||
if (showEyes) for (const e of GE.EYES || []) { if (view.range > 0.9 && e.kind !== 'you') continue; const t = e.label + (e.live ? ' · LIVE' : ''); want.push({ k: 'e:' + e.id + (e.live ? 'L' : ''), lat: e.lat, lon: e.lon, alt: 0.05, pr: e.kind === 'you' ? 2e9 : 6e4, html: `<i class="ei k-${e.kind}"><svg class="ic xs"><use href="#i-${e.kind === 'cctv' ? 'video' : 'eye'}"/></svg></i>${esc(t)}`, w: t.length * 6.4 + 40, cls: 'glass eyelbl k-' + e.kind, onclick: () => { if (e.id === 'you' && GE.live.on) { APP.setTab('capture'); return; } if (e.sit) { const it = GE.feed.items().find((x) => x.post && x.post === e.post); GE.now._item = it || null; GE.sheet.open(e.sit, { post: e.post }); } } }); }
|
||||
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);
|
||||
@@ -126,7 +134,7 @@ 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);
|
||||
const openFollowed = () => (APP._accOpen ? APP._accOpen() : 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)
|
||||
|
||||
+117
-51
@@ -57,74 +57,140 @@ TH.fallback = function (s, canvas) {
|
||||
};
|
||||
|
||||
/* ---------- feed ---------- */
|
||||
const BAND_ORDER = ['all', 'orbit', 'sky', 'ground', 'inside'];
|
||||
NOWV.band = store.get('band', 'all');
|
||||
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>`;
|
||||
const avatarHTML = (k, cls = 'sm') => { const p = GE.PEOPLE[k] || { h: k, hue: 205 }; return `<span class="avatar ${cls}" style="--h:${p.hue}">${esc((p.h || '?')[0].toUpperCase())}</span>`; };
|
||||
GE.avatarHTML = avatarHTML;
|
||||
const bandTag = (b) => `<span class="bt">${GE.BANDS[b].name.toUpperCase()}</span>`;
|
||||
function cvSize(x) { const W0 = x.wide ? 800 : 400; return [W0, Math.round(W0 * x.aspect)]; }
|
||||
function whereHTML(x) { const ago = x.live && x.ago < 1 ? 'LIVE NOW' : x.live ? `LIVE · ${fmt.ago(Date.now() - x.at).replace(' ago', '').toUpperCase()}` : fmt.ago(Date.now() - x.at).toUpperCase(); return `<div class="where"><div class="pl">${esc(x.place)}</div><div class="d mono">${x.dist < 60 ? 'NEAR YOU · ' : ''}${GE.feed.fmtDist(x.dist).toUpperCase()} AWAY · ${ago}</div></div>`; }
|
||||
function socHTML(x) {
|
||||
const r = GE.rooms; const n = r.watching(x.sit); const posts = r.count(x.sit); const sealed = r.sealedCount(x.sit);
|
||||
const ppl = (GE.rooms.byId[x.sit]?.posts || []).slice(0, 3).map((p) => p.by);
|
||||
const stack = (ppl.length ? ppl : ['kora', 'sky', 'geo']).map((k) => avatarHTML(k)).join('');
|
||||
return `<div class="soc">${x.wide ? `<span class="stack">${stack}</span>` : ''}<span>${ic('threads', 'xs')}<b class="num">${fmt.k(n)}</b> in room</span>${sealed ? `<span class="sl">${ic('seal', 'xs')}<b class="num">${sealed}</b> sealed</span>` : `<span><b class="num">${posts}</b> posts</span>`}</div>`;
|
||||
}
|
||||
function frameHTML(x, inner = '') {
|
||||
const [w, h] = cvSize(x);
|
||||
return `<div class="fr" style="aspect-ratio:${1}/${x.aspect}"><canvas width="${w}" height="${h}"></canvas><i class="bk a"></i><i class="bk b"></i><i class="bk c"></i><i class="bk d"></i>
|
||||
<div class="ftop">${bandTag(x.band)}<span class="at">${GE.ARCH[x.arch].toUpperCase()}</span>${x.live ? '<span class="lv"><i></i>LIVE</span>' : ''}${x.mine ? '<span class="mine">YOU</span>' : ''}</div>
|
||||
${inner}${x.tele ? `<div class="tele" data-tele>${esc(x.tele())}</div>` : ''}</div>`;
|
||||
}
|
||||
function cardHTML(x) {
|
||||
const B = GE.BANDS[x.band];
|
||||
if (x.arch === 'room' && !x.recon) return roomCardHTML(x);
|
||||
let inner = '';
|
||||
if (x.arch === 'number') inner = `<div class="numov"><div class="big num">${x.big}<small>${esc(x.unit)}</small></div><div class="nl">${esc(x.label)}</div><svg class="nsp" viewBox="0 0 160 34" preserveAspectRatio="none">${GE.sparkPath(x.spark, 160, 34, B.c, 2)}</svg></div>`;
|
||||
if (x.readout) inner += `<div class="readout num" data-ro>${esc(typeof x.readout === 'function' ? x.readout() : x.readout)}</div>`;
|
||||
if (x.arch === 'witness') inner += `<svg class="fseal"><use href="#i-seal"/></svg>`;
|
||||
if (x.recon) inner += `<div class="r4d"><span class="badge d4">4D</span><span class="badge">Reconstruction</span></div>`;
|
||||
const who = x.by ? (() => { const p = GE.PEOPLE[x.by]; const room = GE.rooms.get(x.sit); const post = room?.posts.find((q) => q.id === x.post); const bd = post ? GE.rooms.badges(room, post).slice(0, 2) : []; return `<div class="who">${avatarHTML(x.by)}<b>@${esc(p.h)}</b>${bd.map(([k, l]) => `<span class="bdg ${k}">${esc(l)}</span>`).join('')}</div>`; })() : '';
|
||||
const cta = x.cta ? `<button class="cta" data-cta>${ic(x.cta[0], 'sm')}${esc(x.cta[1])}</button>` : '';
|
||||
const recon = x.recon ? `<div class="soc"><span><b class="num">23</b> observations</span><span><b class="num">9</b> hypotheses</span><span class="sl">${ic('link', 'xs')}ledger verified</span></div>` : socHTML(x);
|
||||
return `${frameHTML(x, inner)}<div class="fm">${who}<div class="ttl">${esc(x.title)}</div>${whereHTML(x)}${recon}${cta}</div>`;
|
||||
}
|
||||
function roomCardHTML(x) {
|
||||
const room = GE.rooms.get(x.sit); const syn = room.synth; const open = room.posts.find((p) => p.kind === 'question' && !p.answered);
|
||||
const ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5);
|
||||
const line = (cls, lab, t, c) => t ? `<div class="cl ${cls}"><b>${lab}</b><span>${esc(t)}</span>${c ? `<span class="cites">${c.map((z) => `<i>${z}</i>`).join('')}</span>` : ''}</div>` : '';
|
||||
const ev = (x.ev || []).map((e, i) => `<canvas class="evc" data-ev="${i}" width="200" height="150"></canvas>`).join('');
|
||||
const sealed = room.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length; const notes = room.posts.filter((p) => p.note).length;
|
||||
return `<div class="rhead"><span class="rtag">${ic('threads', 'xs')}ROOM</span>${bandTag('inside')}<span class="lvw"><span class="dot pulse"></span><b class="num" data-watch>${fmt.n(room.watching)}</b> watching</span><span class="stack">${ppl.map((k) => avatarHTML(k)).join('')}</span></div>
|
||||
<h3>${esc(x.title)}</h3>${whereHTML(x)}
|
||||
<div class="claims">${line('known', 'KNOWN', syn.known[0]?.[0], syn.known[0]?.[1])}${line('disputed', 'DISPUTED', syn.disputed[0]?.[0], syn.disputed[0]?.[1])}${open ? line('open', 'ASKED', '“' + open.text + '”', null) : line('unknown', 'UNKNOWN', syn.unknown[0]?.[0], syn.unknown[0]?.[1])}</div>
|
||||
${ev ? `<div class="evs">${ev}</div>` : ''}
|
||||
<div class="rfoot"><span><b class="num">${room.posts.length}</b> posts · <b class="num">${sealed}</b> sealed${notes ? ` · <b class="num">${notes}</b> note` : ''}</span><span class="join">Join the room ${ic('chevr', 'xs')}</span></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>
|
||||
const v = $('#v-now'); const now = new Date();
|
||||
const kick = now.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).toUpperCase();
|
||||
v.innerHTML = `<div class="navbar"><span class="wm">GOD’S EYE</span></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 class="hud"><span class="wm">GOD’S EYE</span><i class="hb a"></i><i class="hb b"></i><i class="hb c"></i><i class="hb d"></i><i class="xh"></i><div class="hud-t mono" id="heroTele"></div></div>
|
||||
<div class="lt"><div class="kicker mono"><span class="dot pulse"></span><span>LIVE · ${kick} · <span class="num" id="nowClock">${GE.feed.hmsZ()}</span></span></div>
|
||||
<div class="row"><h1>Right now, at every altitude.</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div>
|
||||
<div class="counts mono"><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num">${fmt.n(GE.SATS.length)}</b> satellites</span><span><b class="num">408</b> cameras</span><span class="w"><b class="num" id="cWit">14</b> witnesses live</span></div></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>`;
|
||||
<div class="now-body"><div class="bandrail" id="bandRail" role="tablist" aria-label="Altitude"></div><div id="feed"></div><div class="feed-end mono">${ic('sparkles', 'xs')} CONCEPT · ILLUSTRATIVE DATA · PEOPLE AND POSTS ARE FICTIONAL</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.renderRail(); NOWV.renderFeed();
|
||||
};
|
||||
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.renderRail = function () {
|
||||
const c = GE.feed.counts();
|
||||
$('#bandRail').innerHTML = BAND_ORDER.map((b) => { const B = GE.BANDS[b]; return `<button class="bcell ${NOWV.band === b ? 'on' : ''}" data-b="${b}" role="tab" aria-selected="${NOWV.band === b}" style="--c:${B ? B.c : '#E8EEF8'}"><i></i><b>${b === 'all' ? 'ALL' : B.name.toUpperCase()}</b><span class="num">${c[b]}</span><em>${b === 'all' ? 'orbit → you' : esc(B.alt)}</em></button>`; }).join('<span class="rarr"></span>');
|
||||
$$('#bandRail .bcell').forEach((el) => el.addEventListener('click', () => { NOWV.band = el.dataset.b; store.set('band', NOWV.band); NOWV.renderRail(); NOWV.renderFeed(); haptic(5); const v = $('#v-now'); const r = $('#bandRail'); if (v.scrollTop > r.offsetTop) v.scrollTo({ top: r.offsetTop - 4, behavior: 'smooth' }); }));
|
||||
};
|
||||
NOWV.cardEl = function (x) {
|
||||
const el = h('article', { class: `card fc a-${x.arch} b-${x.band}${x.wide ? ' wide' : ''}${x.arch === 'room' && !x.recon ? ' room' : ''}${x.mine ? ' mine' : ''}`, 'data-fid': x.id, 'data-id': x.sit, tabindex: 0, role: 'button', 'aria-label': x.title, style: `--c:${GE.BANDS[x.band].c}` });
|
||||
el.innerHTML = cardHTML(x);
|
||||
const go = (e) => { if (e && e.target.closest('[data-cta]')) { e.stopPropagation(); if (x.ride) { NOWV.open(GE.SIT.byId[x.sit], el, { ride: true }); return; } if (x.cta[0] === 'bell') { APP.toast(`${ic('bell', 'sm')} Reminder set · 5 min before the pass`); return; } } NOWV.openItem(x, el); };
|
||||
el.addEventListener('click', go); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
// paint
|
||||
const cv = el.querySelector('.fr canvas');
|
||||
if (cv) NOWV.paint(x, cv);
|
||||
el.querySelectorAll('canvas.evc').forEach((c) => { const [id, o] = x.ev[+c.dataset.ev]; TH.enqueue(() => GE.paint.draw(id, c, { ...o, t: 2 + +c.dataset.ev })); });
|
||||
return el;
|
||||
};
|
||||
NOWV.paint = function (x, cv) {
|
||||
if (x.img) { const im = new Image(); im.onload = () => { const cx = cv.getContext('2d'); const s = Math.max(cv.width / im.width, cv.height / im.height); cx.drawImage(im, (cv.width - im.width * s) / 2, (cv.height - im.height * s) / 2, im.width * s, im.height * s); }; im.src = x.img; return; }
|
||||
if (x.paint === 'globe') { const s = GE.SIT.byId[x.sit]; const cached = TH.done.get(s.id); if (cached) cv.getContext('2d').drawImage(cached, 0, 0, cv.width, cv.height); 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); $$(`[data-id="${s.id}"] .fr canvas`).forEach((z) => z.getContext('2d').drawImage(c2, 0, 0, z.width, z.height)); }); } return; }
|
||||
const key = x.id + '|' + cv.width; const done = NOWV.painted.get(key);
|
||||
const cx = cv.getContext('2d'); cx.fillStyle = '#06080c'; cx.fillRect(0, 0, cv.width, cv.height);
|
||||
if (GE.paint.animated(x.paint)) { NOWV.anim.set(cv, x); TH.enqueue(() => GE.paint.draw(x.paint, cv, { ...x.po, t: performance.now() / 1000 })); return; }
|
||||
if (done) { cx.drawImage(done, 0, 0); return; }
|
||||
TH.enqueue(() => { if (!GE.paint.draw(x.paint, cv, { ...x.po })) return; const c2 = GE.paint.mk(cv.width, cv.height); c2.getContext('2d').drawImage(cv, 0, 0); NOWV.painted.set(key, c2); });
|
||||
};
|
||||
NOWV.painted = new Map(); NOWV.anim = new Map(); NOWV.vis = new Set();
|
||||
NOWV.renderFeed = function () {
|
||||
const host = $('#feed'); if (!host) return; host.innerHTML = ''; NOWV.anim.clear();
|
||||
const list = GE.feed.list(NOWV.band);
|
||||
if (NOWV.band !== 'all') { const B = GE.BANDS[NOWV.band]; host.append(h('div', { class: 'band-h', style: `--c:${B.c}`, html: `<b>${B.name}</b><span>${esc(B.alt)} · ${esc(B.sensor)}</span>` })); }
|
||||
let grid = null, cols = null, hs = [0, 0];
|
||||
const flush = () => { grid = null; };
|
||||
for (const x of list) {
|
||||
const el = NOWV.cardEl(x);
|
||||
if (x.wide) { flush(); host.append(el); continue; }
|
||||
if (!grid) { grid = h('div', { class: 'masonry' }); cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols); host.append(grid); hs = [0, 0]; }
|
||||
const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += (x.aspect || 0.9) + (x.arch === 'number' ? 0.5 : 0.62) + (x.by ? 0.16 : 0) + (x.cta ? 0.2 : 0);
|
||||
}
|
||||
NOWV.observe();
|
||||
};
|
||||
NOWV.observe = function () {
|
||||
if (!('IntersectionObserver' in window)) { NOWV.anim.forEach((x, cv) => NOWV.vis.add(cv)); return; }
|
||||
NOWV.io && NOWV.io.disconnect(); NOWV.vis.clear();
|
||||
NOWV.io = new IntersectionObserver((es) => { for (const e of es) { const cv = e.target; if (e.isIntersecting) NOWV.vis.add(cv); else NOWV.vis.delete(cv); } }, { root: $('#v-now'), rootMargin: '80px' });
|
||||
NOWV.anim.forEach((x, cv) => NOWV.io.observe(cv));
|
||||
};
|
||||
// animated eyes: CCTV and live scenes redraw at ~8 fps while visible
|
||||
NOWV.animate = function () {
|
||||
if (APP.tab !== 'now' || APP.mode || APP.intro) return; const t = performance.now() / 1000;
|
||||
for (const cv of NOWV.vis) { const x = NOWV.anim.get(cv); if (!x) continue; GE.paint.draw(x.paint, cv, { ...x.po, t }); }
|
||||
};
|
||||
setInterval(() => { try { NOWV.animate(); } catch (e) { console.warn(e); } }, 125);
|
||||
NOWV.openItem = function (x, el) {
|
||||
if (x.recon) { APP.setTab('threads'); GE.threads.openWs(x.recon); return; }
|
||||
const s = GE.SIT.byId[x.sit]; NOWV._item = x;
|
||||
if (x.arch === 'room') { GE.sheet.open(x.sit, { post: x.post }); return; }
|
||||
NOWV.open(s, el, { cam: x.cam, post: x.post, seg: x.arch === 'number' || (x.arch === 'sensor' && !x.post) ? 'room' : 'room' });
|
||||
};
|
||||
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)}`;
|
||||
}
|
||||
const ck = $('#nowClock'); if (ck) ck.textContent = GE.feed.hmsZ();
|
||||
const ht = $('#heroTele'); if (ht && GE.mainView) { const v = GE.mainView; const [la, lo, alt] = geo.ll(v.eye); ht.textContent = `ORBIT · ALT ${fmt.n(Math.round(alt))} KM · NADIR ${GE.feed.llz(la, lo)} · TERMINATOR ${W.sun.lat >= 0 ? 'N' : 'S'} · ${GE.feed.hmsZ()}`; }
|
||||
if (APP.tab !== 'now') return;
|
||||
const items = new Map([...GE.feed.user, ...GE.feed.items()].map((x) => [x.id, x]));
|
||||
for (const el of $$('#feed .fc[data-fid]')) { const x = items.get(el.dataset.fid); if (!x) continue; const t = el.querySelector('[data-tele]'); if (t && x.tele) { const s2 = x.tele(); if (t.textContent !== s2) t.textContent = s2; } const ro = el.querySelector('[data-ro]'); if (ro && typeof x.readout === 'function') ro.textContent = x.readout(); const w = el.querySelector('[data-watch]'); if (w) { const r = GE.rooms.byId[x.sit]; if (r) w.textContent = fmt.n(r.watching); } }
|
||||
};
|
||||
GE.rooms.drift = setInterval(() => { for (const r of Object.values(GE.rooms.byId)) r.watching += Math.round((Math.random() - 0.42) * r.rate * 3); }, 2000);
|
||||
|
||||
/* ---------- hero morph: card → live globe ---------- */
|
||||
NOWV.open = function (s, cardEl, o = {}) {
|
||||
const wide = APP.isWide();
|
||||
const thumb = cardEl?.querySelector('.thumb canvas');
|
||||
const thumb = cardEl?.querySelector('.fr canvas') || cardEl?.querySelector('.evc');
|
||||
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);
|
||||
@@ -144,9 +210,9 @@ NOWV.open = function (s, cardEl, o = {}) {
|
||||
}));
|
||||
};
|
||||
NOWV.foldBack = function (id) {
|
||||
const cardEl = $(`#v-now .card[data-id="${id}"]`); const M = $('#morph');
|
||||
const cardEl = (NOWV._morphCard && document.body.contains(NOWV._morphCard) && NOWV._morphCard.dataset.id === id) ? NOWV._morphCard : $(`#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();
|
||||
const thumb = cardEl.querySelector('.fr canvas') || cardEl.querySelector('.evc'); if (!thumb) return false; 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' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* 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 SH = (GE.sheet = { id: null, detent: 'medium', seg: 'room' });
|
||||
const sheetEl = () => $('#sheet');
|
||||
SH.heights = function () {
|
||||
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
|
||||
@@ -19,7 +19,7 @@ SH.setDetent = function (d, animate = true) {
|
||||
SH.open = function (id, o = {}) {
|
||||
const s = GE.SIT.byId[id]; if (!s) return;
|
||||
const W = GE.world;
|
||||
SH.id = id; SH.sit = s;
|
||||
SH.fromTab = APP.mode === 'situation' ? SH.fromTab : APP.tab; SH.id = id; SH.sit = s; SH.opts = o; SH.seg = o.seg || 'room';
|
||||
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
|
||||
@@ -28,9 +28,9 @@ SH.open = function (id, o = {}) {
|
||||
bus.emit('layers', W.layers);
|
||||
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
|
||||
W.auroraK = s.auroraK ?? 0.85;
|
||||
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d);
|
||||
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d); GE.roomUI.labels(s, o.post);
|
||||
// camera
|
||||
const cam = GE.SIT.cameraFor(s);
|
||||
const cam = o.cam || GE.SIT.cameraFor(s);
|
||||
const hs = SH.heights();
|
||||
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
|
||||
SH.targetShift = shiftY;
|
||||
@@ -48,13 +48,14 @@ SH.open = function (id, o = {}) {
|
||||
SH.close = function (silent, keepTime) {
|
||||
if (!SH.id) return; const id = SH.id; const W = GE.world;
|
||||
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null);
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null); GE.roomUI.composer(false);
|
||||
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
|
||||
if (!keepTime) { W.setWindow(null); W.setTime(null); } W.auroraK = 0.85;
|
||||
APP.setMode('');
|
||||
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)); }
|
||||
if (SH.fromTab === 'threads' || SH.fromTab === 'you') APP.setTab(SH.fromTab, { force: true });
|
||||
else 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 }); }
|
||||
};
|
||||
|
||||
@@ -127,8 +128,26 @@ function barsSVG(items, w) {
|
||||
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>`;
|
||||
}
|
||||
const bandOfSit = (s) => (GE.now?._item?.sit === s.id ? GE.now._item.band : { orbit: 'orbit', sky: 'sky', wx: 'sky', eyes: 'ground' }[s.fam] || 'ground');
|
||||
SH.render = function (s) {
|
||||
const c = GE.FAM[s.fam].color; const el = $('#sheetC');
|
||||
const el = $('#sheetC'); const b = bandOfSit(s); const B = GE.BANDS[b]; const room = GE.rooms.get(s.id);
|
||||
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 ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5);
|
||||
el.innerHTML = `<div class="sh-hd"><div class="t"><div class="tags" style="--c:${B.c}"><span class="bt">${B.name.toUpperCase()}</span>${live}<span class="age 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> · <b>${GE.feed.fmtDist(GE.feed.distFromMe(s.lat, s.lon))}</b> from you</div></div></div>
|
||||
<div class="presence-row"><span class="stack">${ppl.map((k) => GE.avatarHTML(k)).join('')}</span><span><b class="num" data-watch>${fmt.n(room.watching)}</b> watching</span><span class="rate">${room.rate} posts/min</span></div>
|
||||
<div class="seg2" id="shSegs"><button data-seg="room" class="${SH.seg === 'room' ? 'on' : ''}">${ic('threads', 'xs')}Room <span class="num">${room.posts.length}</span></button><button data-seg="brief" class="${SH.seg === 'brief' ? 'on' : ''}">${ic('signal', 'xs')}Brief & sources</button></div>
|
||||
<div id="shSeg"></div>`;
|
||||
el.querySelectorAll('[data-seg]').forEach((bt) => bt.addEventListener('click', () => { SH.seg = bt.dataset.seg; el.querySelectorAll('[data-seg]').forEach((x) => x.classList.toggle('on', x === bt)); SH.renderSeg(s); haptic(4); }));
|
||||
SH.renderSeg(s);
|
||||
};
|
||||
SH.renderSeg = function (s) {
|
||||
const host = $('#shSeg'); GE.roomUI.composer(SH.seg === 'room');
|
||||
if (SH.seg === 'room') { GE.roomUI.render(host, s, { post: SH.opts?.post }); return; }
|
||||
SH.renderBrief(s, host);
|
||||
};
|
||||
SH.renderBrief = function (s, el) {
|
||||
const c = GE.FAM[s.fam].color;
|
||||
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 = [
|
||||
@@ -139,8 +158,6 @@ SH.render = function (s) {
|
||||
].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">
|
||||
|
||||
@@ -136,7 +136,10 @@ GUI.drawRibbon = function () {
|
||||
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)'; }
|
||||
const stt = $('#statusTxt'); if (stt) {
|
||||
const alt = (GE.mainView ? geo.ll(GE.mainView.eye)[2] : Cam.range * EARTH_KM); const band = alt > 120 ? 'ORBIT' : alt > 0.8 ? 'SKY' : 'GROUND'; const bc = { ORBIT: 'var(--b-orbit)', SKY: 'var(--b-sky)', GROUND: 'var(--b-ground)' }[band];
|
||||
if (GE.live && GE.live.on) { stt.innerHTML = `You’re live · <b class="num">${fmt.dur((Date.now() - GE.live.t0) / 1000).slice(3)}</b>`; $('#statusTime').textContent = 'return'; $('#status .dot').style.background = ''; }
|
||||
else { stt.innerHTML = `${W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'} <span class="alt mono" style="color:${bc}">${band} · ${alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M'}</span>`; $('#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;
|
||||
@@ -217,5 +220,5 @@ GUI.init = function () {
|
||||
$('#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());
|
||||
$('#status').addEventListener('click', () => { if (GE.live && GE.live.on) { APP.setTab('capture'); return; } GUI.snapNow(); });
|
||||
};
|
||||
@@ -51,32 +51,42 @@ CAP.enter = function () {
|
||||
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.exit = function () { if (!CAP.on) return; CAP.on = false; CAP.cover = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); };
|
||||
CAP.MODES = [['look', 'Look'], ['witness', 'Witness'], ['live', 'Live'], ['upload', 'Upload']];
|
||||
CAP.setMode = function (m) { if (CAP.mode === 'live' && m !== 'live' && CAP.live?.on) { APP.toast(`${ic('live', 'sm')} End the broadcast first`); return; } CAP.mode = m; if (CAP.on) CAP.render(); };
|
||||
CAP.render = function () {
|
||||
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1];
|
||||
el.innerHTML = `<div class="cap-ui">
|
||||
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1]; const m = CAP.mode;
|
||||
const rt = CAP.replyTo && GE.SIT.byId[CAP.replyTo.sit];
|
||||
CAP.cover = m === 'live';
|
||||
el.innerHTML = `<canvas class="livecv ${m === 'live' ? 'on' : ''}" id="liveCv"></canvas><div class="cap-ui m-${m}">
|
||||
<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-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${{ look: `${ic('eye', 'xs')}Look · AR`, witness: `${ic('key', 'xs')}Witness · signing`, live: `${ic('live', 'xs')}Live · sealed every 10 s`, upload: `${ic('gallery', 'xs')}Upload · unverified` }[m]}</span></div>
|
||||
${rt ? `<div class="replyto glass">${ic('threads', 'xs')}<span>Replying in <b>${esc(rt.short || rt.title.split(' — ')[0])}</b></span><button id="rtX" aria-label="Cancel reply">${ic('x', 'xs')}</button></div>` : ''}
|
||||
<div class="sensors glass ${m === 'witness' ? 'on' : ''}" id="sensors"></div>
|
||||
<div id="modeHost"></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>
|
||||
${m === 'look' || m === 'witness' ? `<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">${CAP.MODES.map(([k, l]) => `<button data-m="${k}" class="${m === k ? 'on' : ''}">${l}</button>`).join('')}</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>
|
||||
<button class="shutter s-${m} ${CAP.live?.on ? 'rec' : ''}" id="shutter" aria-label="${{ look: 'Switch to Witness', witness: 'Capture and sign', live: CAP.live?.on ? 'End broadcast' : 'Go live', upload: 'Choose a file' }[m]}"><i></i></button>
|
||||
<button class="circ glass" id="capFlip" aria-label="${m === 'live' ? 'See yourself on the globe' : 'Layers in view'}">${ic(m === 'live' ? 'globe' : '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(); });
|
||||
el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.setMode(b.dataset.m); haptic(6); }));
|
||||
$('#rtX')?.addEventListener('click', () => { CAP.replyTo = null; CAP.render(); });
|
||||
$('#shutter').addEventListener('click', () => { if (m === 'look') { CAP.setMode('witness'); return; } if (m === 'witness') CAP.shoot(); if (m === 'live') (CAP.live?.on ? GE.live.stop() : GE.live.start()); if (m === 'upload') $('#upFile')?.click(); });
|
||||
$('#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`));
|
||||
$('#capFlip').addEventListener('click', () => { if (m === 'live') GE.live.showOnGlobe(); else APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`); });
|
||||
if (m === 'live') GE.live.renderUI($('#modeHost'));
|
||||
if (m === 'upload') GE.upload.renderUI($('#modeHost'));
|
||||
};
|
||||
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;
|
||||
if (CAP.mode === 'live' || CAP.mode === 'upload') { GE.live.frame(); 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
|
||||
@@ -158,8 +168,8 @@ CAP.ceremony = function (b, cv) {
|
||||
<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>
|
||||
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('threads', 'sm')}Attach & post</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">Keep private</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');
|
||||
@@ -173,7 +183,7 @@ CAP.ceremonyFill = async function (b) {
|
||||
$(`.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(); });
|
||||
$('#bdrop').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.attach.open({ kind: 'witness', tier: 'sealed', img: b.thumb, lat: b.lat, lon: b.lon, at: b.at, place: b.place, hash: b.hash, cap: b }); });
|
||||
$('#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(); });
|
||||
};
|
||||
@@ -156,12 +156,20 @@ THR.init = async function () {
|
||||
/* ---------- 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><span class="concept" style="margin-bottom:8px">${ic('sparkles', 'xs')} Concept · illustrative data</span></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>
|
||||
v.innerHTML = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Rooms</div><div class="lt"><div class="kicker mono">${ic('threads', 'xs')} CONVERSATIONS ON THE EVIDENCE</div><div class="row"><h1>Rooms</h1><span class="concept" style="margin-bottom:8px">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="livebar" style="padding-bottom:6px"><span class="presence"><span class="stack">${['nadia', 'lin', 'aiko', 'tomas', 'ines'].map((k) => GE.avatarHTML(k)).join('')}</span></span><span><b class="num">${fmt.n(Object.values(GE.rooms.byId).reduce((a, r) => a + r.watching, 0) || 9400)}</b> people in rooms now</span></div>
|
||||
<div class="rooms-why">Every situation has a room. Posts are <b>Witness</b>, <b>Context</b>, <b>Question</b>, <b>Correction</b> or <b>Prediction</b>, ordered by evidence — never by followers.</div>
|
||||
<div class="sec-h"><h2>Live rooms</h2><span>heating up</span></div><div id="roomsLive" class="rooms-live"></div>
|
||||
<div class="sec-h"><h2>Reconstructions</h2><span>4D · signed · open ledgers</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>`;
|
||||
$('#newThread').addEventListener('click', () => APP.toast(`${ic('threads', 'sm')} New thread · add your first observation`));
|
||||
const host = $('#roomsLive'); const sq = GE.feed.squawkTrack();
|
||||
const extra = [{ id: 'room-sq', arch: 'room', band: 'inside', sit: 'squawk', title: 'Squawk 7700 over the North Atlantic', place: 'Diverting to ' + sq.div.iata, lat: sq.cur[0], lon: sq.cur[1], ago: 3, ev: [['cabin', { seed: 81 }], ['track', { track: sq }]] }, { id: 'room-fi', arch: 'room', band: 'inside', sit: 'fires', title: 'Central Portugal fires', place: 'N2 · Pampilhosa da Serra', lat: 40.13, lon: -8.05, ago: 8, ev: [['firewit', { seed: 71 }], ['firesat', {}]] }];
|
||||
const mine = GE.feed.user.filter((x) => x.sit && !['cyclone', 'quake', 'squawk', 'fires'].includes(x.sit)).map((x) => ({ id: 'room-' + x.sit, arch: 'room', band: 'inside', sit: x.sit, title: GE.SIT.byId[x.sit].title, place: GE.SIT.byId[x.sit].place, lat: x.lat, lon: x.lon, ago: 0 }));
|
||||
const items = [...mine, ...GE.feed.items().filter((x) => x.arch === 'room' && !x.recon), ...extra];
|
||||
for (const x of items) { x.at = x.at || NOW - (x.ago || 1) * 60000; x.dist = GE.feed.distFromMe(x.lat, x.lon); x.wide = true; host.append(GE.now.cardEl(x)); }
|
||||
$$('.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);
|
||||
|
||||
@@ -6,10 +6,12 @@ YOU.render = function () {
|
||||
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="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your eye</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>${GE.feed.user.length}</b>posts</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>Your posts</h2><span>in rooms</span></div>
|
||||
<div class="grp">${GE.feed.user.length ? GE.feed.user.map((x) => { const s = GE.SIT.byId[x.sit]; const p = GE.rooms.byId[x.sit]?.posts.find((q) => q.id === x.post); const t = p?.att?.tier || 'text'; return `<div class="row2" data-post="${x.sit}|${x.post}"><span class="ri" style="--c:var(--b-inside)">${ic(t === 'sealed-live' ? 'live' : t === 'unverified' ? 'gallery' : 'seal')}</span><span class="rt">${esc(x.title)}<small>${esc(s ? (s.short || s.title.split(' — ')[0]) : '')} · ${{ 'sealed-live': 'Sealed live' + (p?.att?.segs ? ' · ' + p.att.segs + ' segments' : ''), sealed: 'Sealed', unverified: 'Unverified upload' }[t] || 'Text'} · ${fmt.ago(Date.now() - x.at)}</small></span><button class="rv">${ic('chevr', 'sm')}</button></div>`; }).join('') : `<div class="row2"><span class="ri" style="--c:var(--b-inside)">${ic('viewfinder')}</span><span class="rt">Nothing posted yet<small>Capture › Witness, Live or Upload, then attach it to a room</small></span></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>
|
||||
@@ -30,6 +32,7 @@ YOU.render = function () {
|
||||
<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-post]').forEach((el) => el.addEventListener('click', () => { const [sid, pid] = el.dataset.post.split('|'); GE.now._item = { band: 'inside', sit: sid }; GE.sheet.open(sid, { post: pid }); }));
|
||||
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 }); }));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Room UI (inside the situation sheet): AI synthesis that cites every line, evidence-first posts with
|
||||
earned badges, place + time anchors that fly the globe and scrub the ribbon, community notes with the
|
||||
bridging rule explained, presence + floating reactions, and Reply → Capture first. */
|
||||
const RUI = (GE.roomUI = { sort: 'evidence' });
|
||||
const KIND_ICON = { witness: 'eye', context: 'layers', question: 'bubble', correction: 'flag', prediction: 'clock' };
|
||||
const tierLabel = (a) => ({ 'sealed-live': `SEALED LIVE${a.segs ? ' · ' + a.segs + ' SEGMENTS' : ''}`, sealed: 'SEALED', unverified: 'UNVERIFIED UPLOAD', eye: 'PUBLIC CAMERA' })[a.tier] || a.tier.toUpperCase();
|
||||
function citeChips(cites) { return (cites || []).map((c) => `<button class="cite" data-cite="${c}">${c}</button>`).join(''); }
|
||||
RUI.postHTML = function (room, p) {
|
||||
const who = GE.PEOPLE[p.by] || { h: p.by, hue: 205 }; const K = GE.rooms.KINDS[p.kind]; const pid = GE.rooms.pid(room, p);
|
||||
const bd = GE.rooms.badges(room, p).map(([k, l]) => `<span class="bdg ${k}">${k === 'sealed' ? ic('seal', 'xs') : ''}${esc(l)}</span>`).join('');
|
||||
const att = p.att ? `<div class="patt ${p.att.tier}"><canvas width="${p.att.img ? 480 : 480}" height="300" data-att></canvas><span class="tier">${p.att.tier.startsWith('sealed') ? ic('seal', 'xs') : ''}${tierLabel(p.att)}</span>${p.live ? '<span class="plive"><span class="dot pulse"></span>LIVE</span>' : ''}</div>` : '';
|
||||
const anc = [p.place ? `<button class="anc" data-anc="place">${ic('location', 'xs')}${esc(p.place.label)}</button>` : '', `<button class="anc" data-anc="time">${ic('clock', 'xs')}${fmt.hm(p.time || p.at)}Z</button>`].join('');
|
||||
const ans = p.answered ? (() => { const a = room.posts.find((q) => q.id === p.answered); return a ? `<div class="answered">${ic('check', 'xs')}Answered by <b>@${esc(GE.PEOPLE[a.by].h)}</b> · ${GE.rooms.badges(room, a).map(([, l]) => esc(l)).join(' · ')}</div>` : ''; })() : p.kind === 'question' ? `<div class="answered open">${ic('arrowup', 'xs')}Open question · pinned to the top until someone answers with evidence</div>` : '';
|
||||
const note = p.note ? `<div class="note"><div class="nh">${ic('people', 'xs')}Readers added context</div><p>${esc(p.note.text)} ${citeChips(p.note.cites)}</p>
|
||||
<div class="bridge"><div class="br"><span>Raters who usually lean <b>A</b></span><i style="--v:${p.note.helpfulA}"></i><b class="num">${Math.round(p.note.helpfulA * 100)}%</b></div><div class="br"><span>Raters who usually lean <b>B</b></span><i style="--v:${p.note.helpfulB}"></i><b class="num">${Math.round(p.note.helpfulB * 100)}%</b></div></div>
|
||||
<details><summary>Why is this note showing?</summary><p>A note only appears when people who usually <i>disagree</i> with each other both rate it helpful. Raters are grouped by how they have voted on past notes, not by what they say about themselves. Here ${p.note.ratersA} raters from one side and ${p.note.ratersB} from the other agreed, so it is shown. Brigading by one side can't make a note visible.</p></details></div>` : '';
|
||||
const rx = p.reacts || {}; const rxs = [['eye', rx.eye], ['heart', rx.heart], ['exclamation', rx.alert]].filter(([, n]) => n).map(([i, n]) => `<button class="rxb" data-rx="${i}">${ic(i, 'xs')}<span class="num">${fmt.k(n)}</span></button>`).join('');
|
||||
return `<article class="post k-${p.kind}${p.note ? ' noted' : ''}${p.mine ? ' mine' : ''}" data-p="${p.id}">
|
||||
<div class="ph">${GE.avatarHTML(p.by)}<div class="pw"><div><b>@${esc(who.h)}</b><span class="pid">${pid}</span><span class="tm">${fmt.ago(Date.now() - p.at)}</span></div><div class="bds">${bd}</div></div><span class="kind k-${p.kind}">${ic(KIND_ICON[p.kind], 'xs')}${K.name.toUpperCase()}</span></div>
|
||||
<div class="pt">${esc(p.text)}${p.resolves ? `<span class="resolves">resolves ${esc(p.resolves)}</span>` : ''}</div>
|
||||
${att}<div class="ancs">${anc}${citeChips(p.cites)}</div>${note}${ans}
|
||||
<div class="pf">${rxs}<span class="rep">${ic('bubble', 'xs')}<span class="num">${p.replies || 0}</span></span><button class="rcap" data-reply>${ic('viewfinder', 'xs')}Reply with a capture</button></div></article>`;
|
||||
};
|
||||
RUI.synthHTML = function (room) {
|
||||
const S = room.synth; const sec = (k, lab, cls) => (S[k] || []).length ? `<div class="sy ${cls}"><b>${lab}</b>${S[k].map(([t, c]) => `<p>${esc(t)} ${citeChips(c)}</p>`).join('')}</div>` : `<div class="sy ${cls}"><b>${lab}</b><p class="none">Nothing ${k} right now.</p></div>`;
|
||||
const L = room.ledger; const last = room.synthEntry || L[L.length - 1];
|
||||
return `<div class="synth" id="synth"><div class="syh">${ic('sparkles', 'xs')}<span>AI synthesis · every line cites a post or an observation</span><button class="regen" id="regen">${ic('wand', 'xs')}Re-synthesize</button></div>
|
||||
<div id="syBody">${sec('known', 'KNOWN', 'kn')}${sec('disputed', 'DISPUTED', 'di')}${sec('unknown', 'UNKNOWN', 'un')}</div>
|
||||
<div class="syf mono" id="syFoot">${room.synthModel ? esc(room.synthModel) + ' · ' : ''}${room.dropped ? room.dropped + ' uncited line removed · ' : ''}prompt + ${room.posts.length + Object.keys(room.src || {}).length} inputs in the ledger${last ? ` · #${last.i} ${last.hash.slice(0, 10)}…` : ''}</div></div>`;
|
||||
};
|
||||
RUI.render = function (host, s, o = {}) {
|
||||
const room = GE.rooms.get(s.id); RUI.room = room; RUI.sit = s;
|
||||
const list = GE.rooms.ordered(room, RUI.sort);
|
||||
host.innerHTML = `${RUI.synthHTML(room)}
|
||||
<div class="sortrow"><span class="sl mono">ORDER</span>${[['evidence', 'Evidence first'], ['latest', 'Latest'], ['questions', 'Questions']].map(([k, l]) => `<button class="chip sm ${RUI.sort === k ? 'on' : ''}" data-sort="${k}">${l}</button>`).join('')}</div>
|
||||
<div class="posts" id="posts">${list.map((p) => RUI.postHTML(room, p)).join('')}</div>
|
||||
<div class="srcs room-srcs"><div class="h3">Sources in this room</div>${Object.entries(room.src || {}).map(([k, v]) => `<div class="r"><span class="tagid">${k}</span><b style="font-weight:500">${esc(v)}</b></div>`).join('')}</div>
|
||||
<p class="fine">Posts are ordered by evidence, then time — never by followers. Concept · illustrative data; people and posts are fictional.</p>`;
|
||||
RUI.bind(host, room, s);
|
||||
if (o.post) setTimeout(() => RUI.focusPost(o.post, true), 60);
|
||||
};
|
||||
RUI.bind = function (host, room, s) {
|
||||
host.querySelectorAll('[data-sort]').forEach((b) => b.addEventListener('click', () => { RUI.sort = b.dataset.sort; RUI.render(host, s); haptic(4); }));
|
||||
$('#regen')?.addEventListener('click', () => RUI.resynth(room, s));
|
||||
host.querySelectorAll('.post').forEach((el) => {
|
||||
const p = room.posts.find((q) => q.id === el.dataset.p);
|
||||
const cv = el.querySelector('[data-att]'); if (cv && p.att) RUI.paintAtt(cv, p);
|
||||
el.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button, summary, details'); if (b && !b.matches('[data-anc]')) return;
|
||||
RUI.anchor(p, b?.dataset.anc); });
|
||||
el.querySelector('[data-reply]')?.addEventListener('click', (e) => { e.stopPropagation(); RUI.replyCapture(s, p); });
|
||||
el.querySelectorAll('[data-rx]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); RUI.float(b.dataset.rx); const k = { eye: 'eye', heart: 'heart', exclamation: 'alert' }[b.dataset.rx]; p.reacts = p.reacts || {}; p.reacts[k] = (p.reacts[k] || 0) + 1; b.querySelector('.num').textContent = fmt.k(p.reacts[k]); haptic(4); }));
|
||||
});
|
||||
host.querySelectorAll('[data-cite]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); RUI.cite(b.dataset.cite); }));
|
||||
};
|
||||
RUI.paintAtt = function (cv, p) {
|
||||
if (p.att.img) { const im = new Image(); im.onload = () => { const cx = cv.getContext('2d'); const sc = Math.max(cv.width / im.width, cv.height / im.height); cx.drawImage(im, (cv.width - im.width * sc) / 2, (cv.height - im.height * sc) / 2, im.width * sc, im.height * sc); }; im.src = p.att.img; return; }
|
||||
const draw = () => GE.paint.draw(p.att.paint, cv, { ...p.att.o, t: performance.now() / 1000 });
|
||||
GE.thumbs.enqueue(draw);
|
||||
if (GE.paint.animated(p.att.paint)) { RUI.animCvs = RUI.animCvs || new Set(); RUI.animCvs.add([cv, p]); }
|
||||
};
|
||||
setInterval(() => { if (!RUI.animCvs || APP.mode !== 'situation') return; for (const e of RUI.animCvs) { const [cv, p] = e; if (!document.body.contains(cv)) { RUI.animCvs.delete(e); continue; } const r = cv.getBoundingClientRect(); if (r.bottom < 0 || r.top > innerHeight) continue; GE.paint.draw(p.att.paint, cv, { ...p.att.o, t: performance.now() / 1000 }); } }, 160);
|
||||
RUI.focusPost = function (id, fly) {
|
||||
const el = $(`.post[data-p="${id}"]`); if (!el) return; el.classList.add('hl'); setTimeout(() => el.classList.remove('hl'), 2200);
|
||||
const sc = $('#sheetC'); if (sc && GE.sheet.detent === 'large') sc.scrollTo({ top: el.offsetTop - 80, behavior: 'smooth' });
|
||||
if (fly) { const p = RUI.room.posts.find((q) => q.id === id); if (p?.place) RUI.anchor(p, 'place', true); }
|
||||
};
|
||||
RUI.anchor = function (p, which, quiet) {
|
||||
const W = GE.world; const s = RUI.sit;
|
||||
if (p.place && which !== 'time') { const wide = APP.isWide(); const hs = GE.sheet.heights(); Cam.flyTo({ lat: p.place.lat - 0.12, lon: p.place.lon - 0.05, range: 0.03, heading: 0.4, tilt: 0.95, shiftY: wide ? 0 : hs[GE.sheet.detent === 'large' ? 'medium' : GE.sheet.detent] / hs.H, shiftX: 0 }, { duration: 1.8 }); GE.roomUI.labels(s, p.id); }
|
||||
const at = p.time || p.at; if (which !== 'place') W.setTime(at);
|
||||
if (!quiet) APP.toast(`${ic(p.place && which !== 'time' ? 'location' : 'clock', 'sm')} ${p.place && which !== 'time' ? esc(p.place.label) + ' · ' : ''}${fmt.hm(at)} UTC · ${fmt.ago(Date.now() - at)}`);
|
||||
if (GE.sheet.detent === 'large' && !APP.isWide()) GE.sheet.setDetent('medium');
|
||||
haptic(5);
|
||||
};
|
||||
RUI.cite = function (c) {
|
||||
const room = RUI.room; if (!room) return;
|
||||
if (c[0] === 'P') { const p = GE.rooms.byPid(room, c); if (p) { if (GE.sheet.detent !== 'large' && !APP.isWide()) GE.sheet.setDetent('large'); setTimeout(() => RUI.focusPost(p.id), 120); } return; }
|
||||
if (c[0] === 'N') { const p = room.posts.find((q) => q.note); if (p) { if (GE.sheet.detent !== 'large' && !APP.isWide()) GE.sheet.setDetent('large'); setTimeout(() => RUI.focusPost(p.id), 120); } return; }
|
||||
APP.toast(`${ic('hash', 'sm')} ${c} · ${esc((room.src || {})[c] || 'observation')}`, 2600);
|
||||
};
|
||||
// speech glyphs on the globe for anchored posts
|
||||
RUI.labels = function (s, hl) {
|
||||
const room = GE.rooms.get(s.id); for (const k of [...GE.extraLabels.keys()]) if (k.startsWith('p:')) GE.extraLabels.delete(k);
|
||||
for (const p of room.posts) { if (!p.place) continue; const who = GE.PEOPLE[p.by] || { h: p.by }; GE.extraLabels.set('p:' + p.id, { lat: p.place.lat, lon: p.place.lon, alt: 0.03, html: `<i class="sg k-${p.kind}">${ic(KIND_ICON[p.kind], 'xs')}</i><span>@${esc(who.h)}</span>`, w: who.h.length * 6.4 + 46, cls: 'glass speech' + (p.id === hl ? ' hl' : ''), onclick: () => { if (GE.sheet.detent === 'peek') GE.sheet.setDetent('medium'); RUI.focusPost(p.id); } }); }
|
||||
};
|
||||
/* presence & reactions */
|
||||
RUI.float = function (icon) { const host = $('#rx'); if (!host) return; const sp = h('span', { html: ic(icon || ['heart', 'eye', 'heart', 'exclamation', 'bolt'][Math.floor(Math.random() * 5)], 'sm'), style: `--dx:${(Math.random() - 0.5) * 30}px` }); sp.dataset.i = icon || ''; host.append(sp); setTimeout(() => sp.remove(), 2700); };
|
||||
setInterval(() => { if (APP.mode === 'situation' && GE.sheet.seg === 'room' && !document.hidden) { if (Math.random() < 0.7) RUI.float(); const w = $('#sheetC [data-watch]'); if (w && RUI.room) w.textContent = fmt.n(RUI.room.watching); } }, 900);
|
||||
|
||||
/* composer: Capture first, text second */
|
||||
RUI.composer = function (show) {
|
||||
const c = $('#roomComp'); if (!c) return; c.classList.toggle('on', !!show); if (!show) return;
|
||||
if (c.dataset.bound) return; c.dataset.bound = '1';
|
||||
c.innerHTML = `<button class="capbtn" id="rcCap" aria-label="Reply with a capture">${ic('viewfinder')}<span>Capture</span></button><div class="rcin glass"><div class="kinds" id="rcKinds">${Object.entries(GE.rooms.KINDS).map(([k, K]) => `<button data-k="${k}" class="${k === 'context' ? 'on' : ''}">${ic(KIND_ICON[k], 'xs')}${K.name}</button>`).join('')}</div><div class="rcrow"><input id="rcText" placeholder="Add context, ask, correct…" autocomplete="off"><button class="send" id="rcSend" aria-label="Post">${ic('arrowup', 'sm')}</button></div></div>`;
|
||||
let kind = 'context';
|
||||
c.querySelectorAll('[data-k]').forEach((b) => b.addEventListener('click', () => { kind = b.dataset.k; c.querySelectorAll('[data-k]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
$('#rcCap').addEventListener('click', () => RUI.replyCapture(RUI.sit));
|
||||
const inp = $('#rcText'); inp.addEventListener('focus', () => c.classList.add('typing')); inp.addEventListener('blur', () => setTimeout(() => c.classList.remove('typing'), 150));
|
||||
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#rcSend').click(); } });
|
||||
$('#rcSend').addEventListener('click', async () => {
|
||||
const t = inp.value.trim(); if (!t || !RUI.room) return; inp.value = '';
|
||||
const p = { id: 'u' + Date.now(), kind, by: 'you', at: Date.now(), text: t, mine: true, reacts: {}, replies: 0 };
|
||||
RUI.room.posts.push(p); const e = await GE.rooms.append(RUI.room, 'post.' + kind, 'you', { id: p.id, text: t });
|
||||
RUI.render($('#shSeg'), RUI.sit); setTimeout(() => RUI.focusPost(p.id), 80);
|
||||
APP.toast(`${ic('link', 'sm')} Posted · ledger #${e.i} · text ranks below evidence`, 2600); haptic(8);
|
||||
});
|
||||
};
|
||||
RUI.replyCapture = function (s, p) {
|
||||
GE.capture.replyTo = { sit: s.id, post: p?.id };
|
||||
APP.toast(`${ic('viewfinder', 'sm')} Capture a reply for ${esc(s.short || s.title.split(' — ')[0])}`, 2000);
|
||||
GE.sheet.close(true); APP.setTab('capture'); GE.capture.setMode('witness');
|
||||
};
|
||||
|
||||
/* AI synthesis: window.claude sample on click, scripted fallback; uncited lines are dropped */
|
||||
RUI.resynth = async function (room, s, o = {}) {
|
||||
const btn = $('#regen'); if (!btn || btn.disabled) return; btn.disabled = true; btn.classList.add('busy');
|
||||
const body = $('#syBody'); const foot = $('#syFoot');
|
||||
const posts = room.posts.map((p) => `[${GE.rooms.pid(room, p)}] ${GE.rooms.KINDS[p.kind].name} by @${(GE.PEOPLE[p.by] || { h: p.by }).h} (${GE.rooms.badges(room, p).map((b) => b[1]).join(', ') || 'no evidence badges'}), ${fmt.ago(Date.now() - p.at)}: ${p.text}${p.note ? ` — Community note [N1]: ${p.note.text}` : ''}`).join('\n');
|
||||
const srcs = Object.entries(room.src || {}).map(([k, v]) => `[${k}] ${v}`).join('\n');
|
||||
const prompt = `You summarise a live discussion about a real-world situation for an app that separates evidence from claims.\nSituation: ${s.title} — ${s.place}. Posts and people are illustrative.\nObservations:\n${srcs}\nPosts:\n${posts}\nWrite at most 6 lines, each starting with exactly one of "KNOWN:", "DISPUTED:" or "UNKNOWN:". Every line must end with one or more citations in square brackets like [P1] [O2] [N1]. Prefer sealed witness posts and observations over unverified posts. Never state anything you cannot cite. Plain text, no markdown.`;
|
||||
body.innerHTML = '<div class="sy kn"><b>WRITING</b><p class="stream" id="syStream">…</p></div>';
|
||||
let out = '', model = 'scripted fallback (no AI available here)';
|
||||
const show = (t) => { out = t; const el = $('#syStream'); if (el) el.textContent = t || '…'; };
|
||||
let sample = null; if (!o.auto) { try { sample = window.claude?.use ? await window.claude.use('sample') : null; } catch { sample = null; } }
|
||||
if (o.auto) model = 'auto-update (scripted) · tap Re-synthesize to ask AI';
|
||||
if (sample) { try { const r = await sample(prompt, { onText: ({ text }) => show(text) }); show(r?.text ?? out); model = 'claude · window.claude sample'; } catch { sample = null; } }
|
||||
if (!sample) {
|
||||
const S = room.synth; const lines = [...S.known.map(([t, c]) => `KNOWN: ${t} ${c.map((x) => `[${x}]`).join(' ')}`), ...S.disputed.map(([t, c]) => `DISPUTED: ${t} ${c.map((x) => `[${x}]`).join(' ')}`), ...S.unknown.map(([t, c]) => `UNKNOWN: ${t} ${c.map((x) => `[${x}]`).join(' ')}`)];
|
||||
const mine = room.posts.filter((p) => p.mine && p.att); if (mine.length) { const p = mine[mine.length - 1]; lines.splice(1, 0, `KNOWN: A new ${p.att.tier.startsWith('sealed') ? 'sealed' : 'unverified'} capture from @you at ${p.place?.label || 'the scene'} adds a view of the situation. [${GE.rooms.pid(room, p)}]`); }
|
||||
lines.push('UNKNOWN: Whether conditions improve in the next hour — the posts do not say.');
|
||||
const full = lines.join('\n'); for (let i = 1; i <= full.length; i += 4) { show(full.slice(0, i)); await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 8)); } show(full);
|
||||
}
|
||||
// parse: keep only cited lines
|
||||
const next = { known: [], disputed: [], unknown: [] }; let dropped = 0;
|
||||
for (const ln of out.split('\n')) { const m = /^\s*(KNOWN|DISPUTED|UNKNOWN)\s*:\s*(.+)$/i.exec(ln); if (!m) continue; const cites = [...m[2].matchAll(/\[([PONS]\d+)\]/g)].map((x) => x[1]); const text = m[2].replace(/\s*\[[^\]]+\]/g, '').trim(); if (!cites.length) { dropped++; continue; } next[m[1].toLowerCase()].push([text, [...new Set(cites)]]); }
|
||||
if (next.known.length + next.disputed.length + next.unknown.length) room.synth = next;
|
||||
room.dropped = dropped; room.synthModel = model;
|
||||
const e = await GE.rooms.append(room, 'ai.synthesis', 'claude', { prompt: await sha256(prompt), inputs: room.posts.map((p) => GE.rooms.pid(room, p)).concat(Object.keys(room.src || {})), model, output: await sha256(out), dropped });
|
||||
room.synthEntry = e;
|
||||
const wrap = $('#synth'); if (wrap) { wrap.outerHTML = RUI.synthHTML(room); $('#regen')?.addEventListener('click', () => RUI.resynth(room, s)); $('#synth').querySelectorAll('[data-cite]').forEach((b) => b.addEventListener('click', (ev) => { ev.stopPropagation(); RUI.cite(b.dataset.cite); })); }
|
||||
APP.toast(`${ic('sparkles', 'sm')} Synthesis recorded · ledger #${e.i}${dropped ? ' · ' + dropped + ' uncited line dropped' : ''}`, 2800);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
/* Live, Upload and the Attach sheet.
|
||||
Live: a simulated broadcast (rendered lakefront in a gale) that seals a segment every 10 s into a real
|
||||
SHA-256 hash chain signed with the device key; you appear on the globe as an eye with a view cone.
|
||||
Upload: a real file input; EXIF GPS/time are read with a tiny parser; you place it on a map; it is
|
||||
labelled Unverified everywhere. Attach: suggests where it belongs, kind, precision, hologram; posting
|
||||
lands in the room, the feed and on the globe. */
|
||||
const LV = (GE.live = { on: false, segs: [], viewers: 0 });
|
||||
GE.capture.live = LV;
|
||||
const CAPm = GE.capture; const SPOTm = CAPm.spot;
|
||||
/* ---------- eyes on the globe (people and cameras that are looking right now) ---------- */
|
||||
GE.EYES = [
|
||||
{ id: 'basco', lat: 20.4485, lon: 121.9705, hdg: 205, fov: 64, label: '@nadia_basco', kind: 'witness', live: true, sit: 'cyclone', post: 'cy1' },
|
||||
{ id: 'kaoh', lat: 22.612, lon: 120.268, hdg: 250, fov: 72, label: 'HBR-07', kind: 'cctv', sit: 'cyclone', post: 'cy5' },
|
||||
{ id: 'shib', lat: 35.6597, lon: 139.7002, hdg: 135, fov: 84, label: 'CAM 0412', kind: 'cctv', sit: 'shibuya' },
|
||||
{ id: 'ines', lat: 40.13, lon: -8.05, hdg: 80, fov: 60, label: '@ines_coimbra', kind: 'witness', sit: 'fires', post: 'fi1' },
|
||||
{ id: 'ola', lat: 69.649, lon: 18.957, hdg: 10, fov: 74, label: '@ola_tromso', kind: 'witness', sit: 'aurora' },
|
||||
{ id: 'sak', lat: 31.597, lon: 130.56, hdg: 95, fov: 48, label: 'VOLCANO CAM 2', kind: 'cctv', sit: 'sakurajima' },
|
||||
{ id: 'rafa', lat: 18.357, lon: 121.637, hdg: 40, fov: 60, label: '@rafa_aparri', kind: 'witness', sit: 'cyclone', post: 'cy6' },
|
||||
];
|
||||
GE.eyeLines = function (rangeR) {
|
||||
const out = []; const D = clamp((rangeR || 1) * EARTH_KM * 0.07, 0.25, 420);
|
||||
for (const e of GE.EYES) {
|
||||
const col = e.kind === 'you' ? [1, 0.86, 0.5, 0.95] : e.kind === 'cctv' ? [1, 0.66, 0.26, 0.7] : [0.96, 0.82, 0.48, 0.8];
|
||||
const tip = geo.vec(e.lat, e.lon, 0.03 + D * 0.004); const half = (e.fov / 2) * D2R; const h0 = e.hdg * D2R; const cs = [];
|
||||
for (const [da, z] of [[-half, 0], [half, 0], [half, D * 0.32], [-half, D * 0.32]]) { const [la, lo] = geo.dest(e.lat, e.lon, h0 + da, D); cs.push(geo.vec(la, lo, z)); }
|
||||
for (const c of cs) out.push({ pts: [tip, c], col });
|
||||
out.push({ pts: [...cs, cs[0]], col: [col[0], col[1], col[2], col[3] * 0.8] });
|
||||
const mid = geo.dest(e.lat, e.lon, h0, D); out.push({ pts: [tip, geo.vec(mid[0], mid[1], D * 0.16)], col: [col[0], col[1], col[2], 0.35], dashed: 1 });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ---------- Live ---------- */
|
||||
const CHAT_LIVE = [['amara', 'Same at Oak Street — the trail is underwater'], ['kora', 'Stay back from the edge please 🙏'], ['sky', 'That spray must be 8 m'], ['geo', 'Buoy says 3.4 m waves, matches what we see'], ['hydro', 'Is the harbour gate closed?'], ['amara', 'Streaming this into the gale room'], ['ais', 'Two freighters sheltering off the breakwater'], ['kora', 'The sealed badge is so reassuring tbh']];
|
||||
const nearestSits = (lat, lon, n = 3) => GE.SIT.list.filter((s) => !s.dyn).map((s) => ({ s, km: RM.kmBetween(lat, lon, s.lat, s.lon) })).sort((a, b) => a.km - b.km).slice(0, n);
|
||||
LV.renderUI = function (host) {
|
||||
const near = nearestSits(SPOTm.lat, SPOTm.lon, 2); LV.sit = near[0].s;
|
||||
if (!LV.on) {
|
||||
host.innerHTML = `<div class="lv-pre glass strong"><div class="lvph"><span class="lvpill pre"><i></i>READY</span><b>Go live as a sealed eye</b></div>
|
||||
<p>Your camera becomes a sensor on the globe. Every 10 seconds the stream is sealed into a hash chain and signed on this device, with GPS, heading and motion. Rooms nearby can watch.</p>
|
||||
<div class="lvnear">${near.map(({ s, km }) => `<span>${ic('threads', 'xs')}${esc(s.short || s.title.split(' — ')[0])} · ${GE.feed.fmtDist(km)}</span>`).join('')}</div>
|
||||
<div class="lvfine mono">SIMULATED CAMERA · RENDERED SCENE · NO CAMERA OR MIC IS USED</div></div>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `<div class="lv-top"><span class="lvpill"><i></i>LIVE <b class="num" id="lvT">00:00</b></span><span class="lvv glass">${ic('eye', 'xs')}<b class="num" id="lvV">${LV.viewers}</b></span><span class="lvseal">${ic('seal', 'xs')}SEALED LIVE</span></div>
|
||||
<div class="lv-to glass">${ic('threads', 'xs')}<span>In the <b>${esc(LV.sit.short || LV.sit.title)}</b> room · ${GE.feed.fmtDist(RM.kmBetween(SPOTm.lat, SPOTm.lon, LV.sit.lat, LV.sit.lon))}</span></div>
|
||||
<div class="lv-chat" id="lvChat"></div><div class="lvrx" id="lvRx"></div>
|
||||
<div class="lv-tele mono" id="lvTele"></div><div class="lv-chain" id="lvChain"></div>`;
|
||||
LV.drawChain(); LV.chatRestore();
|
||||
};
|
||||
LV.start = async function () {
|
||||
if (LV.on) return; await GE.crypto.KEY.get();
|
||||
Object.assign(LV, { on: true, t0: Date.now(), segT: Date.now(), segs: [], viewers: 3, prev: '0'.repeat(64), chat: [], hdg: 64 });
|
||||
GE.EYES.push({ id: 'you', lat: SPOTm.lat, lon: SPOTm.lon, hdg: LV.hdg, fov: 62, label: 'You · LIVE', kind: 'you', live: true, sit: LV.sit?.id });
|
||||
LV.timer = setInterval(() => LV.seal(), 10000); LV.soc = setInterval(() => LV.social(), 1500);
|
||||
haptic([10, 30, 10]); CAPm.render(); APP.renderAccessory(); APP.toast(`${ic('live', 'sm')} You’re live · first segment seals in 10 s`);
|
||||
};
|
||||
LV.stop = function () {
|
||||
if (!LV.on) return; clearInterval(LV.timer); clearInterval(LV.soc); LV.on = false; LV.t1 = Date.now();
|
||||
const me = GE.EYES.find((e) => e.id === 'you'); if (me) { me.live = false; me.label = 'You · replay'; }
|
||||
CAPm.render(); APP.renderAccessory();
|
||||
const seg = LV.segs[LV.segs.length - 1];
|
||||
GE.attach.open({ kind: 'witness', tier: 'sealed-live', img: LV.posterImg || seg?.thumb, lat: SPOTm.lat, lon: SPOTm.lon, at: LV.t0, place: SPOTm.name, segs: LV.segs.length, dur: (LV.t1 - LV.t0) / 1000, hash: seg?.hash, hdg: LV.hdg, live: true });
|
||||
};
|
||||
LV.seal = async function () {
|
||||
const cv = $('#liveCv'); const small = GE.paint.mk(320, 180);
|
||||
if (cv && cv.width) small.getContext('2d').drawImage(cv, 0, 0, 320, 180); else GE.paint.draw('seawall', small, { skyline: true, noOSD: true, seed: 53, t: performance.now() / 1000 });
|
||||
const blob = await new Promise((r) => small.toBlob(r, 'image/jpeg', 0.7)); const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const seg = { i: LV.segs.length, t0: LV.segT, t1: Date.now(), frame: await sha256(bytes), bytes: bytes.length, gps: { lat: +SPOTm.lat.toFixed(6), lon: +SPOTm.lon.toFixed(6), acc: 4 }, hdg: LV.hdg, imu: 500, prev: LV.prev };
|
||||
seg.hash = await sha256(canon(seg)); seg.sig = await GE.crypto.signHex(seg.hash); seg.thumb = small.toDataURL('image/jpeg', 0.6);
|
||||
if (!LV.posterImg) { const pc = GE.paint.mk(480, 300); pc.getContext('2d').drawImage(cv || small, 0, 0, 480, 300); LV.posterImg = pc.toDataURL('image/jpeg', 0.8); }
|
||||
LV.segs.push(seg); LV.prev = seg.hash; LV.segT = seg.t1; haptic(6);
|
||||
LV.drawChain(true); APP.renderAccessory();
|
||||
};
|
||||
LV.drawChain = function (flash) {
|
||||
const host = $('#lvChain'); if (!host) return;
|
||||
const blocks = LV.segs.map((s) => `<div class="seg ok" title="segment ${s.i} · ${s.hash}"><span class="si mono">#${String(s.i + 1).padStart(3, '0')}</span><img src="${s.thumb}" alt=""><span class="sh mono">${s.hash.slice(0, 8)}</span><span class="sk">${ic('seal', 'xs')}${s.sig ? 'signed' : 'hashed'}</span></div>`).join('<i class="ln"></i>');
|
||||
host.innerHTML = `${blocks}${LV.segs.length ? '<i class="ln"></i>' : ''}<div class="seg cur"><span class="si mono">#${String(LV.segs.length + 1).padStart(3, '0')}</span><div class="fill"><i id="lvFill"></i></div><span class="sh mono">sealing…</span></div>`;
|
||||
host.scrollLeft = host.scrollWidth; if (flash) host.lastElementChild.previousElementSibling?.previousElementSibling?.classList.add('flash');
|
||||
};
|
||||
LV.social = function () {
|
||||
if (!LV.on) return; LV.viewers += 1 + Math.floor(Math.random() * (LV.viewers > 60 ? 18 : 6));
|
||||
const chat = $('#lvChat'); const k = Math.floor((Date.now() - LV.t0) / 1500);
|
||||
const ev = k % 3 === 0 ? { join: ['amara', 'kora', 'sky', 'geo', 'hydro', 'ais', 'ola'][k % 7] } : { msg: CHAT_LIVE[k % CHAT_LIVE.length] };
|
||||
LV.chat.push(ev); if (LV.chat.length > 4) LV.chat.shift(); if (chat) LV.chatRestore();
|
||||
const rx = $('#lvRx'); if (rx) for (let i = 0; i < 2; i++) { const sp = h('span', { html: ic(['heart', 'eye', 'heart', 'bolt'][Math.floor(Math.random() * 4)], 'sm'), style: `--dx:${(Math.random() - 0.5) * 30}px` }); rx.append(sp); setTimeout(() => sp.remove(), 2700); }
|
||||
};
|
||||
LV.chatRestore = function () {
|
||||
const chat = $('#lvChat'); if (!chat) return;
|
||||
chat.innerHTML = LV.chat.map((e) => e.join ? `<div class="lj">${GE.avatarHTML(e.join)}<span><b>@${esc(GE.PEOPLE[e.join].h)}</b> joined</span></div>` : `<div class="bub">${GE.avatarHTML(e.msg[0])}<div class="bx glass"><b>@${esc(GE.PEOPLE[e.msg[0]].h)}</b>${esc(e.msg[1])}</div></div>`).join('');
|
||||
};
|
||||
LV.frame = function () {
|
||||
const cv = $('#liveCv'); if (!cv || CAPm.mode !== 'live') return;
|
||||
const W0 = Math.round(cv.clientWidth * 0.8), H0 = Math.round(cv.clientHeight * 0.8); if (!W0 || !H0) return;
|
||||
if (cv.width !== W0 || cv.height !== H0) { cv.width = W0; cv.height = H0; }
|
||||
const now = performance.now(); if (LV._lf && now - LV._lf < 40) return; LV._lf = now;
|
||||
GE.paint.draw('seawall', cv, { skyline: true, handheld: true, noOSD: true, seed: 53, t: now / 1000 });
|
||||
if (!LV.on) return;
|
||||
const tEl = $('#lvT'); if (tEl) tEl.textContent = fmt.dur((Date.now() - LV.t0) / 1000).slice(3);
|
||||
const vEl = $('#lvV'); if (vEl) vEl.textContent = fmt.n(LV.viewers);
|
||||
const f = $('#lvFill'); if (f) f.style.width = clamp((Date.now() - LV.segT) / 10000, 0, 1) * 100 + '%';
|
||||
const te = $('#lvTele'); if (te) te.textContent = `${GE.feed.llz(SPOTm.lat, SPOTm.lon)} · HDG ${String(LV.hdg + Math.round(Math.sin(now / 900) * 3)).padStart(3, '0')}° · ±4 M · ALT 182 M · SEG ${String(LV.segs.length + 1).padStart(3, '0')} · ${GE.feed.hmsZ()}`;
|
||||
};
|
||||
LV.showOnGlobe = function () {
|
||||
const back = LV.on; APP.setTab('globe'); Cam.flyTo({ lat: SPOTm.lat - 0.035, lon: SPOTm.lon - 0.05, range: 0.016, heading: 0.75, tilt: 1.0, shiftY: 0.06, shiftX: 0 }, { duration: 2.4 });
|
||||
APP.toast(back ? `${ic('eye', 'sm')} That cone is you — still live` : `${ic('eye', 'sm')} Your view cone on the globe`, 2600);
|
||||
};
|
||||
|
||||
/* ---------- EXIF (read + write) ---------- */
|
||||
const EX = (GE.exif = {});
|
||||
EX.parse = function (buf) {
|
||||
try {
|
||||
const v = new DataView(buf); if (v.getUint16(0) !== 0xffd8) return null; let o = 2;
|
||||
while (o < v.byteLength - 4) { const m = v.getUint16(o); if ((m & 0xff00) !== 0xff00) break; const len = v.getUint16(o + 2); if (m === 0xffe1 && v.getUint32(o + 4) === 0x45786966) return EX.tiff(v, o + 10); if (m === 0xffda) break; o += 2 + len; }
|
||||
} catch (e) { console.warn('exif', e); }
|
||||
return null;
|
||||
};
|
||||
EX.tiff = function (v, t) {
|
||||
const le = v.getUint16(t) === 0x4949; const u16 = (p) => v.getUint16(p, le), u32 = (p) => v.getUint32(p, le);
|
||||
const ifd = (p) => { const n = u16(p); const e = {}; for (let i = 0; i < n; i++) { const q = p + 2 + i * 12; e[u16(q)] = { type: u16(q + 2), count: u32(q + 4), off: q + 8 }; } return e; };
|
||||
const val = (en) => {
|
||||
if (!en) return null; const { type, count } = en; const size = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }[type] || 1; const p = size * count > 4 ? t + u32(en.off) : en.off;
|
||||
if (type === 2) { let s = ''; for (let i = 0; i < count; i++) { const c = v.getUint8(p + i); if (!c) break; s += String.fromCharCode(c); } return s.trim(); }
|
||||
if (type === 5 || type === 10) { const out = []; for (let i = 0; i < count; i++) { const a = type === 5 ? u32(p + i * 8) : v.getInt32(p + i * 8, le), b = type === 5 ? u32(p + i * 8 + 4) : v.getInt32(p + i * 8 + 4, le); out.push(b ? a / b : 0); } return count === 1 ? out[0] : out; }
|
||||
if (type === 3) return u16(p); if (type === 4) return u32(p); return v.getUint8(p);
|
||||
};
|
||||
const i0 = ifd(t + u32(t + 4)); const out = { make: val(i0[0x010f]), model: val(i0[0x0110]), date: val(i0[0x0132]) };
|
||||
if (i0[0x8769]) { const ex = ifd(t + val(i0[0x8769])); out.date = val(ex[0x9003]) || out.date; }
|
||||
if (i0[0x8825]) { const g = ifd(t + val(i0[0x8825])); const dms = (a) => (a && a.length === 3 ? a[0] + a[1] / 60 + a[2] / 3600 : null); const la = dms(val(g[2])), lo = dms(val(g[4])); if (la != null && lo != null) { out.lat = la * (val(g[1]) === 'S' ? -1 : 1); out.lon = lo * (val(g[3]) === 'W' ? -1 : 1); } const al = val(g[6]); if (al != null) out.alt = al; }
|
||||
return out;
|
||||
};
|
||||
EX.when = (s) => { const m = /^(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)/.exec(s || ''); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]) : null; };
|
||||
// write a little-endian TIFF/EXIF block with Make, Model, DateTimeOriginal and GPS (used by the sample)
|
||||
EX.build = function ({ make, model, date, lat, lon, alt }) {
|
||||
const B = []; const u8 = (x) => B.push(x & 255), u16 = (x) => { u8(x); u8(x >> 8); }, u32 = (x) => { u16(x & 0xffff); u16(x >>> 16); };
|
||||
const str = (s) => { const a = [...new TextEncoder().encode(s), 0]; if (a.length % 2) a.push(0); return a; };
|
||||
const mk = str(make), md = str(model), dt = str(date);
|
||||
const IFD0 = 8, D0 = IFD0 + 2 + 5 * 12 + 4, pMk = D0, pMd = pMk + mk.length, pDt = pMd + md.length, EXIF = pDt + dt.length, pDo = EXIF + 2 + 12 + 4, GPS = pDo + dt.length, pLa = GPS + 2 + 7 * 12 + 4, pLo = pLa + 24, pAl = pLo + 24;
|
||||
const ent = (tag, type, count, v) => { u16(tag); u16(type); u32(count); u32(v); };
|
||||
const asc2 = (c) => c.charCodeAt(0);
|
||||
u16(0x4949); u16(42); u32(IFD0);
|
||||
u16(5); ent(0x010f, 2, mk.length - (mk[mk.length - 1] === 0 && mk[mk.length - 2] === 0 ? 1 : 0), pMk); ent(0x0110, 2, md.length - (md[md.length - 1] === 0 && md[md.length - 2] === 0 ? 1 : 0), pMd); ent(0x0132, 2, 20, pDt); ent(0x8769, 4, 1, EXIF); ent(0x8825, 4, 1, GPS); u32(0);
|
||||
B.push(...mk, ...md, ...dt);
|
||||
u16(1); ent(0x9003, 2, 20, pDo); u32(0); B.push(...dt);
|
||||
u16(7); ent(0, 1, 4, 0x00000302); ent(1, 2, 2, asc2(lat >= 0 ? 'N' : 'S')); ent(2, 5, 3, pLa); ent(3, 2, 2, asc2(lon >= 0 ? 'E' : 'W')); ent(4, 5, 3, pLo); ent(5, 1, 1, 0); ent(6, 5, 1, pAl); u32(0);
|
||||
const rat = (x) => { const d = Math.floor(x), mm = Math.floor((x - d) * 60), ss = Math.round(((x - d) * 60 - mm) * 60 * 100); u32(d); u32(1); u32(mm); u32(1); u32(ss); u32(100); };
|
||||
rat(Math.abs(lat)); rat(Math.abs(lon)); u32(Math.round((alt || 0) * 10)); u32(10);
|
||||
const tiff = new Uint8Array(B); const seg = new Uint8Array(4 + 6 + tiff.length); const L = 2 + 6 + tiff.length;
|
||||
seg.set([0xff, 0xe1, L >> 8, L & 255, 0x45, 0x78, 0x69, 0x66, 0, 0]); seg.set(tiff, 10); return seg;
|
||||
};
|
||||
EX.inject = (jpeg, app1) => { const out = new Uint8Array(jpeg.length + app1.length); out.set(jpeg.subarray(0, 2)); out.set(app1, 2); out.set(jpeg.subarray(2), 2 + app1.length); return out; };
|
||||
const exifDate = (ms) => { const d = new Date(ms); const z = (n) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}:${z(d.getUTCMonth() + 1)}:${z(d.getUTCDate())} ${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`; };
|
||||
|
||||
/* ---------- Upload ---------- */
|
||||
const UP = (GE.upload = { f: null });
|
||||
UP.renderUI = function (host) {
|
||||
const f = UP.f;
|
||||
if (!f) {
|
||||
host.innerHTML = `<div class="up glass strong"><div class="uph"><b>Add a photo or video of a place</b><span class="tierb unv">UNVERIFIED</span></div>
|
||||
<p>Uploads weren’t sealed when they were taken, so they carry an <b>Unverified</b> label everywhere. A hypothesis can cite one, but it can never become an observation.</p>
|
||||
<label class="drop"><input type="file" id="upFile" accept="image/*,video/*" hidden>${ic('gallery', 'lg')}<b>Choose from your library</b><span>A JPEG with location in its EXIF places itself</span></label>
|
||||
<button class="btn sm" id="upSample" style="width:100%">${ic('sparkles', 'sm')}No photo handy? Use a sample with GPS in its EXIF</button>
|
||||
<p class="fine mono">READ LOCALLY WITH FILEREADER · NOTHING LEAVES THIS PAGE</p></div>`;
|
||||
$('#upFile').addEventListener('change', (e) => { const file = e.target.files && e.target.files[0]; if (file) UP.load(file); });
|
||||
$('#upSample').addEventListener('click', () => UP.sample());
|
||||
return;
|
||||
}
|
||||
const ex = f.exif || {}; const has = f.lat != null;
|
||||
host.innerHTML = `<div class="up glass strong loaded"><div class="upimg"><img src="${f.img}" alt="Your upload"><span class="tierb unv">UNVERIFIED</span></div>
|
||||
<div class="exif mono"><div><span>FILE</span><b>${esc(f.name)} · ${fmt.k(f.size / 1024)} KB</b></div><div><span>CAMERA</span><b>${esc([ex.make, ex.model].filter(Boolean).join(' ') || '—')}</b></div><div><span>TAKEN</span><b>${ex.date ? esc(ex.date) : '—'}</b></div><div><span>GPS</span><b>${ex.lat != null ? GE.feed.llz(ex.lat, ex.lon) + ' · from EXIF' : 'none in file — place it'}</b></div></div>
|
||||
<div class="placer"><canvas id="upMap" width="640" height="300"></canvas><div class="pin" id="upPin">${ic('location', 'sm')}</div><div class="hint mono" id="upLL"></div></div>
|
||||
<div class="uptime"><span>When</span><input type="range" id="upT" min="-4320" max="0" step="5" value="${Math.round(((f.at || Date.now()) - Date.now()) / 60000)}"><b class="num" id="upTv"></b></div>
|
||||
<div class="btnrow"><button class="btn sm" id="upRe">Choose another</button><button class="btn gold sm" id="upGo">${ic('threads', 'sm')}Continue</button></div></div>`;
|
||||
UP.drawMap(); UP.bindPin();
|
||||
const t = $('#upT'); const tv = $('#upTv'); const upd = () => { f.at = Date.now() + +t.value * 60000; tv.textContent = `${fmt.hm(f.at)} UTC · ${fmt.ago(Date.now() - f.at)}`; }; t.addEventListener('input', upd); upd();
|
||||
$('#upRe').addEventListener('click', () => { UP.f = null; UP.renderUI(host); });
|
||||
$('#upGo').addEventListener('click', () => GE.attach.open({ kind: 'witness', tier: 'unverified', img: f.img, lat: f.lat, lon: f.lon, at: f.at, place: f.name, upload: true }));
|
||||
};
|
||||
UP.load = function (file) {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const buf = r.result; const ex = file.type === 'image/jpeg' || /\.jpe?g$/i.test(file.name) ? EX.parse(buf) : null;
|
||||
const f = { name: file.name || 'upload', size: file.size, type: file.type, exif: ex, lat: ex?.lat ?? null, lon: ex?.lon ?? null, at: EX.when(ex?.date) || file.lastModified || Date.now() };
|
||||
if (f.lat == null) { f.lat = GE.ME.lat; f.lon = GE.ME.lon; f.guess = true; }
|
||||
const url = URL.createObjectURL(file);
|
||||
const done = (cv) => { f.img = cv.toDataURL('image/jpeg', 0.82); UP.f = f; URL.revokeObjectURL(url); UP.renderUI($('#modeHost')); APP.toast(ex?.lat != null ? `${ic('location', 'sm')} Placed from EXIF GPS · ${GE.feed.llz(f.lat, f.lon)}` : `${ic('location', 'sm')} No GPS in this file — drag the pin`, 2600); };
|
||||
if (file.type.startsWith('video')) { const v = document.createElement('video'); v.muted = true; v.playsInline = true; v.src = url; v.addEventListener('loadeddata', () => { v.currentTime = Math.min(0.5, v.duration / 2 || 0); }, { once: true }); v.addEventListener('seeked', () => { const cv = GE.paint.mk(640, Math.round(640 * (v.videoHeight / v.videoWidth || 0.56))); cv.getContext('2d').drawImage(v, 0, 0, cv.width, cv.height); done(cv); }, { once: true }); v.addEventListener('error', () => APP.toast('Could not read that video here'), { once: true }); return; }
|
||||
const im = new Image(); im.onload = () => { const sc = Math.min(1, 1280 / Math.max(im.width, im.height)); const cv = GE.paint.mk(im.width * sc, im.height * sc); cv.getContext('2d').drawImage(im, 0, 0, cv.width, cv.height); done(cv); }; im.onerror = () => APP.toast('Could not read that image'); im.src = url;
|
||||
};
|
||||
r.onerror = () => APP.toast('Could not read that file'); r.readAsArrayBuffer(file);
|
||||
};
|
||||
UP.sample = async function () {
|
||||
const cv = GE.paint.mk(1200, 900); GE.paint.draw('volcano', cv, { noOSD: true, t: 1 });
|
||||
const blob = await new Promise((r) => cv.toBlob(r, 'image/jpeg', 0.86)); const jpg = new Uint8Array(await blob.arrayBuffer());
|
||||
const app1 = EX.build({ make: 'Concept', model: 'Sample Phone', date: exifDate(Date.now() - 23 * 60000), lat: 31.5937, lon: 130.5626, alt: 4.2 });
|
||||
const file = new File([EX.inject(jpg, app1)], 'IMG_4021.JPG', { type: 'image/jpeg', lastModified: Date.now() - 20 * 60000 }); UP.load(file);
|
||||
};
|
||||
UP.drawMap = function () {
|
||||
const f = UP.f; const cv = $('#upMap'); if (!cv) return; const span = 4.2; const mv = GE.paint.mapView(f.lat, f.lon, span, cv.width, cv.height); UP.mv = mv;
|
||||
const ctx = cv.getContext('2d'); GE.paint.mapBase(ctx, mv, { lights: 1.1, relief: 0.8, land: '#18202b', sea: '#05080c', coast: '#5b6e86' }); GE.paint.graticule(ctx, mv, 1);
|
||||
for (const { s, km } of nearestSits(f.lat, f.lon, 6)) { const x = mv.X(s.lon), y = mv.Y(s.lat); if (x < 0 || y < 0 || x > cv.width || y > cv.height) continue; ctx.fillStyle = '#FF6A3D'; ctx.beginPath(); ctx.arc(x, y, 6, 0, TAU); ctx.fill(); GE.paint.label(ctx, (s.short || s.title).toUpperCase(), x + 10, y, 15, '#fff'); }
|
||||
GE.paint.scaleBar(ctx, mv, 50, 18, cv.height - 18);
|
||||
};
|
||||
UP.bindPin = function () {
|
||||
const f = UP.f; const pin = $('#upPin'), cv = $('#upMap'); const place = () => { const r = cv.getBoundingClientRect(); const sx = r.width / cv.width; pin.style.left = UP.mv.X(f.lon) * sx + 'px'; pin.style.top = UP.mv.Y(f.lat) * sx + 'px'; $('#upLL').textContent = `${GE.feed.llz(f.lat, f.lon)} · ${f.guess ? 'GUESSED — DRAG ME' : f.moved ? 'PLACED BY YOU' : 'FROM EXIF'}`; };
|
||||
requestAnimationFrame(place); let drag = false;
|
||||
const mv = (e) => { if (!drag) return; const r = cv.getBoundingClientRect(); const sx = cv.width / r.width; const x = clamp((e.clientX - r.left) * sx, 0, cv.width), y = clamp((e.clientY - r.top) * sx, 0, cv.height); f.lat = UP.mv.lat(y); f.lon = UP.mv.lon(x); f.moved = true; f.guess = false; place(); };
|
||||
pin.addEventListener('pointerdown', (e) => { drag = true; pin.setPointerCapture(e.pointerId); e.preventDefault(); }); pin.addEventListener('pointermove', mv); pin.addEventListener('pointerup', () => { drag = false; haptic(4); });
|
||||
cv.addEventListener('click', (e) => { drag = true; mv(e); drag = false; });
|
||||
};
|
||||
|
||||
/* ---------- Attach sheet ---------- */
|
||||
const AT = (GE.attach = {});
|
||||
const bandOfFam = (f) => ({ orbit: 'orbit', sky: 'sky', wx: 'sky', eyes: 'ground' })[f] || 'ground';
|
||||
AT.open = function (c) {
|
||||
AT.c = c; const near = nearestSits(c.lat, c.lon, 3); const pre = CAPm.replyTo?.sit || (near[0].km < 400 ? near[0].s.id : 'new');
|
||||
AT.sel = pre; AT.kind = c.kind || 'witness'; AT.prec = c.upload ? '100' : 'exact'; AT.holo = !c.upload;
|
||||
const opts = [...(CAPm.replyTo && !near.some((n) => n.s.id === CAPm.replyTo.sit) ? [{ s: GE.SIT.byId[CAPm.replyTo.sit], km: RM.kmBetween(c.lat, c.lon, GE.SIT.byId[CAPm.replyTo.sit].lat, GE.SIT.byId[CAPm.replyTo.sit].lon) }] : []), ...near];
|
||||
const tier = c.tier === 'sealed-live' ? `SEALED LIVE · ${c.segs} SEGMENTS · ${fmt.dur(c.dur || 0).slice(3)}` : c.tier === 'sealed' ? 'SEALED · ECDSA P-256' : 'UNVERIFIED UPLOAD';
|
||||
const ov = $('#attachOv');
|
||||
ov.innerHTML = `<div class="att glass strong"><div class="grab"></div>
|
||||
<div class="ath"><div class="atimg"><img src="${c.img || ''}" alt=""><span class="tierb ${c.tier === 'unverified' ? 'unv' : ''}">${c.tier === 'unverified' ? '' : ic('seal', 'xs')}${tier}</span></div><div><h2>Where does this belong?</h2><p class="mono">${GE.feed.llz(c.lat, c.lon)} · ${fmt.hm(c.at)} UTC${c.hash ? ' · ' + c.hash.slice(0, 10) + '…' : ''}</p></div></div>
|
||||
<div class="ath2">Suggested from where and when it was taken</div>
|
||||
<div class="sugs">${opts.map(({ s, km }) => `<button class="sug ${AT.sel === s.id ? 'on' : ''}" data-s="${s.id}" style="--c:${GE.BANDS[bandOfFam(s.fam)].c}"><i></i><span><b>${esc(s.short || s.title.split(' — ')[0])}</b><small>${GE.feed.fmtDist(km)} away · ${s.live ? 'live now' : 'replay'} · ${fmt.k(GE.rooms.watching(s.id))} in room</small></span><em>${ic('check', 'xs')}</em></button>`).join('')}
|
||||
<button class="sug ${AT.sel === 'new' ? 'on' : ''}" data-s="new" style="--c:#fff"><i></i><span><b>New place</b><small>Start a room at ${GE.feed.llz(c.lat, c.lon)}</small></span><em>${ic('check', 'xs')}</em></button></div>
|
||||
<div class="ath2">Post as</div><div class="kinds2">${Object.entries(GE.rooms.KINDS).map(([k, K]) => `<button data-k="${k}" class="${AT.kind === k ? 'on' : ''}">${K.name}</button>`).join('')}</div>
|
||||
<textarea id="atNote" rows="2" placeholder="${c.live ? 'Waves are coming over the lakefront trail…' : 'What does this show? (optional)'}"></textarea>
|
||||
<div class="ath2">Share location as</div><div class="prec">${[['exact', 'Exact'], ['100', '100 m'], ['1000', '1 km']].map(([k, l]) => `<button data-p="${k}" class="${AT.prec === k ? 'on' : ''}">${l}</button>`).join('')}</div>
|
||||
<p class="fine2">${c.tier === 'unverified' ? 'An upload has no seal, so precision is all anyone gets.' : 'The seal still proves the exact point to verifiers without publishing it.'}</p>
|
||||
<div class="row2 holo"><span class="ri" style="--c:var(--b-inside)">${ic('cube')}</span><span class="rt">Leave it here as a hologram<small>People who come to this spot later can find it</small></span><button class="sw2 ${AT.holo ? 'on' : ''}" id="atHolo" role="switch" aria-label="Hologram"></button></div>
|
||||
<p class="fine2 priv">${ic('eyeslash', 'xs')} Faces and plates are blurred on-device before upload in the native build. This web concept does not blur — a disclosed limitation.</p>
|
||||
<div class="btnrow"><button class="btn" id="atX">Cancel</button><button class="btn gold" id="atGo">${ic('arrowup', 'sm')}Post</button></div></div>`;
|
||||
ov.classList.add('on');
|
||||
ov.querySelectorAll('[data-s]').forEach((b) => b.addEventListener('click', () => { AT.sel = b.dataset.s; ov.querySelectorAll('[data-s]').forEach((x) => x.classList.toggle('on', x === b)); AT.label(); haptic(4); }));
|
||||
ov.querySelectorAll('[data-k]').forEach((b) => b.addEventListener('click', () => { AT.kind = b.dataset.k; ov.querySelectorAll('[data-k]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
ov.querySelectorAll('[data-p]').forEach((b) => b.addEventListener('click', () => { AT.prec = b.dataset.p; ov.querySelectorAll('[data-p]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
$('#atHolo').addEventListener('click', (e) => { AT.holo = !AT.holo; e.currentTarget.classList.toggle('on', AT.holo); });
|
||||
$('#atX').addEventListener('click', () => ov.classList.remove('on'));
|
||||
$('#atGo').addEventListener('click', () => AT.post());
|
||||
AT.label();
|
||||
};
|
||||
AT.label = function () { const b = $('#atGo'); if (!b) return; const s = GE.SIT.byId[AT.sel]; b.innerHTML = `${ic('arrowup', 'sm')}Post to ${esc(s ? (s.short || s.title.split(' — ')[0]) : 'a new room')}`; };
|
||||
AT.post = async function () {
|
||||
const c = AT.c; let sitId = AT.sel;
|
||||
if (sitId === 'new') { sitId = 'mine-' + Date.now(); GE.SIT.list.push({ id: sitId, short: 'Your place', fam: 'eyes', kind: 'place', title: 'New place · ' + (c.place || 'your capture'), place: GE.feed.llz(c.lat, c.lon), lat: c.lat, lon: c.lon, camera: { lat: c.lat - 0.01, lon: c.lon - 0.01, range: 0.01, heading: 0.4, tilt: 1.0 }, layers: ['signed', 'holo', 'cctv'], source: 'You', live: true, watchers: 12, updatedAt: Date.now(), metric: () => ['', ''], metrics: () => [{ v: 1, u: '', l: 'Captures', s: 'You' }, { v: 0, u: '', l: 'Replies', s: '' }, { v: '—', u: '', l: 'Nearby rooms', s: '' }], brief: { lede: 'A place you started. Others nearby will see it.', series: [], sources: [] } }); GE.SIT.byId[sitId] = GE.SIT.list[GE.SIT.list.length - 1]; }
|
||||
const s = GE.SIT.byId[sitId]; const room = GE.rooms.get(sitId);
|
||||
const q = AT.prec === 'exact' ? 0 : AT.prec === '100' ? 0.001 : 0.01; const rd = (x) => (q ? Math.round(x / q) * q : x);
|
||||
const km = RM.kmBetween(c.lat, c.lon, s.lat, s.lon);
|
||||
const note = ($('#atNote').value || '').trim() || (c.live ? 'Live from the lakefront: waves are breaking over the trail and the path is closed.' : c.upload ? 'Photo from my library, placed from its EXIF.' : 'Sealed capture from here.');
|
||||
const p = { id: 'y' + Date.now(), kind: AT.kind, by: 'you', at: c.at || Date.now(), text: note, mine: true, place: { lat: rd(c.lat), lon: rd(c.lon), label: c.place || 'Your capture' }, att: { img: c.img, tier: c.tier, segs: c.segs, distKm: c.tier === 'unverified' ? null : Math.min(km, 4.9) }, reacts: {}, replies: 0, prec: AT.prec };
|
||||
if (CAPm.replyTo?.post) p.replyTo = CAPm.replyTo.post;
|
||||
room.posts.push(p); const e = await GE.rooms.append(room, 'post.' + AT.kind, 'you', { id: p.id, text: note, att: { tier: c.tier, hash: c.hash || null, segs: c.segs || 0 }, place: p.place, precision: AT.prec });
|
||||
// feed
|
||||
const tierT = c.tier === 'sealed-live' ? 'SEALED LIVE' : c.tier === 'sealed' ? 'SEALED' : 'UNVERIFIED';
|
||||
GE.feed.user.unshift({ id: 'f' + p.id, mine: true, arch: 'witness', band: 'inside', sit: sitId, post: p.id, by: 'you', img: c.img, aspect: 0.75, title: note.length > 90 ? note.slice(0, 88) + '…' : note, place: c.place || s.place, lat: c.lat, lon: c.lon, ago: 0, at: Date.now(), dist: 0, prio: 999,
|
||||
tele: () => `WITNESS · ${tierT} · ${AT.prec === 'exact' ? '±4 M' : AT.prec === '100' ? '±100 M' : '±1 KM'} · ${GE.feed.fmtDist(km).toUpperCase()} FROM EVENT` });
|
||||
// globe: hologram + an eye with a cone for sealed captures
|
||||
if (AT.holo) GE.world.userHolos.push({ lat: rd(c.lat), lon: rd(c.lon), title: 'Your capture · ' + (c.place || ''), by: 'you', at: c.at });
|
||||
if (c.tier !== 'unverified' && !GE.EYES.some((x) => x.id === 'you')) GE.EYES.push({ id: 'you', lat: c.lat, lon: c.lon, hdg: c.hdg ?? 64, fov: 62, label: 'You', kind: 'you', sit: sitId, post: p.id });
|
||||
const me = GE.EYES.find((x) => x.id === 'you'); if (me) { me.sit = sitId; me.post = p.id; }
|
||||
$('#attachOv').classList.remove('on'); CAPm.replyTo = null; if (c.upload) UP.f = null;
|
||||
GE.now.renderRail?.(); GE.now.renderFeed(); GE.you.render();
|
||||
APP.toast(`${ic('threads', 'sm')} Posted to ${esc(s.short || s.title.split(' — ')[0])} · ledger #${e.i}`, 2600); haptic([8, 30, 8]);
|
||||
GE.now._item = { band: 'inside', sit: sitId }; APP.setTab('globe', { force: true });
|
||||
setTimeout(() => { GE.sheet.open(sitId, { post: p.id }); setTimeout(() => GE.roomUI.resynth(GE.rooms.get(sitId), s, { auto: true }), 900); }, 250);
|
||||
};
|
||||
@@ -34,7 +34,8 @@ function frame(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()) {
|
||||
if (globeVisible() && GE.capture.on && GE.capture.cover && !APP.isWide()) { GE.capture.frame(null); }
|
||||
else if (globeVisible()) {
|
||||
const [cw, chh] = sizeCanvas();
|
||||
if (now - lastDyn > 40) { W.updateDynamic(); lastDyn = now; }
|
||||
const view = Cam.view(cw, chh); GE.mainView = view;
|
||||
@@ -50,36 +51,52 @@ function frame(now) {
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) running = false; else if (!running) { running = true; lastT = performance.now(); requestAnimationFrame(frame); } });
|
||||
|
||||
/* ---------- cold open ---------- */
|
||||
/* ---------- cold open: a descent from orbit to a person's sealed frame ---------- */
|
||||
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 CITIES2 = [{ lat: 40.7585, lon: -73.9855, name: '7TH AVE & W 45TH ST', cam: { lat: 40.7525, lon: -73.9905, range: 0.0016, heading: 0.62, tilt: 1.18 } }, { lat: 35.6595, lon: 139.7005, name: 'SHIBUYA SCRAMBLE', cam: { lat: 35.652, lon: 139.694, range: 0.0024, heading: 0.6, tilt: 1.12 } }, { lat: 41.8789, lon: -87.6359, name: 'STATE & MADISON', cam: { lat: 41.872, lon: -87.643, range: 0.0018, heading: 0.6, tilt: 1.15 } }, { lat: 51.5136, lon: -0.1365, name: 'OXFORD CIRCUS', cam: { lat: 51.508, lon: -0.143, range: 0.0024, heading: 0.6, tilt: 1.12 } }];
|
||||
const tgt = CITIES2.map((c) => ({ c, e: astro.elev(Date.now(), c.lat, c.lon) })).sort((a, b) => a.e - b.e)[0].c;
|
||||
let ended = false;
|
||||
const end = () => {
|
||||
if (!APP.intro) return; APP.intro = false; GE.introFade = 1; W.setTime(null);
|
||||
if (ended) return; ended = true; 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.cancel(); Cam.set(finalCam); if (!APP.isWide()) { Cam.set({ ...finalCam, range: finalCam.range * 0.7 }); Cam.flyTo(finalCam, { duration: 1.2 }); }
|
||||
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) * (HASH.slow ? +HASH.slow : 1); const t0 = performance.now();
|
||||
GE.introFade = 0;
|
||||
if (!red) Cam.flyTo(finalCam, { duration: T });
|
||||
const k = HASH.slow ? +HASH.slow : 1; const TA = (red ? 0.2 : 4.4) * k, TB = TA + 0.8 * k, TC = TB + 2.4 * k;
|
||||
if (!red) { Cam.set({ lat: tgt.lat - 18, lon: tgt.lon - 50, range: 6.4, heading: 0, tilt: 0, shiftY: 0, shiftX: 0 }); Cam.flyTo({ ...tgt.cam, shiftY: 0, shiftX: 0 }, { duration: TA * 0.95 }); }
|
||||
else Cam.set(finalCam);
|
||||
GE.introFade = 0; const t0 = performance.now(); const cv = $('#coldCv'); $('#coldTele').textContent = `WITNESS · SEALED · ±4 M · ${tgt.name}`;
|
||||
const caps = [[120, 'ORBIT · GEO IR · 35,786 KM'], [30, 'ORBIT · ISS · 412 KM · 7.66 KM/S'], [0.8, 'SKY · FL350 · 487 KT · ADS-B'], [-1, 'GROUND · CAM 0412 · 1.2 KM']];
|
||||
const pos = (altKm) => clamp(1 - (Math.log10(Math.max(0.05, altKm)) + 1.3) / (Math.log10(36000) + 1.3), 0, 1) * 0.72;
|
||||
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);
|
||||
if (ended) return; const el = (performance.now() - t0) / 1000;
|
||||
GE.introFade = ease.out(clamp(el / 1.2, 0, 1)); cold.classList.add('s0');
|
||||
const alt = GE.mainView ? Math.max(0.05, geo.ll(GE.mainView.eye)[2]) : 36000;
|
||||
const lad = $('#ladder'); const H = lad.clientHeight;
|
||||
if (el < TA) {
|
||||
const band = alt > 120 ? 'orbit' : alt > 0.8 ? 'sky' : 'ground';
|
||||
lad.querySelectorAll('.lr').forEach((r) => r.classList.toggle('on', r.dataset.b === band));
|
||||
$('#lmark').style.top = (pos(alt) * H).toFixed(1) + 'px';
|
||||
$('#coldAlt').textContent = 'ALT ' + (alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M');
|
||||
const cp = caps.find(([a]) => alt > a) || caps[3]; $('#coldCap').textContent = cp[1];
|
||||
} else {
|
||||
cold.classList.add('s3'); lad.querySelectorAll('.lr').forEach((r) => r.classList.toggle('on', r.dataset.b === 'inside')); $('#lmark').style.top = H - 8 + 'px';
|
||||
GE.paint.draw('crossing', cv, { seed: 31, cam: '@', rec: false, noOSD: true, osdY: -1, t: el });
|
||||
if (el > TB) cold.classList.add('s4');
|
||||
if (el > TC) { setTimeout(end, 300); return; }
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); Cam.set(finalCam); end(); });
|
||||
cold.addEventListener('click', () => { Cam.set(finalCam); end(); });
|
||||
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); end(); });
|
||||
cold.addEventListener('click', () => end());
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
Reference in new issue
Block a user