diff --git a/mobile/globe/API.md b/mobile/globe/API.md new file mode 100644 index 000000000..17bd73085 --- /dev/null +++ b/mobile/globe/API.md @@ -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 +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 +``` + +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. diff --git a/mobile/globe/compositor.js b/mobile/globe/compositor.js new file mode 100644 index 000000000..62ef1981b --- /dev/null +++ b/mobile/globe/compositor.js @@ -0,0 +1,1601 @@ +/** + * The card compositor (DESIGN.md §11.2 sensor cards): thumbnail(situation) + * renders a still in an offscreen 2D canvas, never the Cesium viewer. + * + * base imagery → Esri World Imagery tiles (CORS-clean) or NASA GIBS + * Black Marble for orbit cards; camera frame for eye cards + * real overlays → the data that describes the situation (NOAA IR with a + * cold-top enhancement, radar, lightning by age, NHC cone, + * track and wind radii, aircraft with velocity leaders, + * quake rings, fire + wind-blown smoke, SGP4 ground tracks, + * jet-stream streamlines, transit vehicles, GNSS hex heat) + * grade → per altitude band (orbit violet night, sky cool, ground + * warm amber) and a vignette. No text, no brackets: the + * shell draws framing in HTML so it stays crisp. + * + * Results are object URLs, cached in memory and (when available) the Cache + * API; tiles are shared across cards; at most three cards render at once. + */ +import { twoline2satrec } from 'satellite.js'; +import { satPosition } from './layers/orbit.js'; +import { radiiRing, RADII_COLORS } from './layers/hazards.js'; +import { SCENES, EYE_FRAMES, dbzOf } from './situations.js'; +import { cctvFrameUrl } from './feeds.js'; +import { aqiColor, sampleField } from './feedsWeather.js'; +import { strikeLevel } from './layers/weather.js'; +import { BLACK_MARBLE_URL } from './imagery.js'; + +const D2R = Math.PI / 180; +const ESRI_TILE = (z, x, y) => + `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`; +const BLACK_MARBLE_TILE = (z, x, y) => + BLACK_MARBLE_URL.replace('{z}', z).replace('{y}', y).replace('{x}', x); +const GIBS_TILE = (layer, day, ext, level) => (z, x, y) => + `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/${day}/GoogleMapsCompatible_Level${level}/${z}/${y}/${x}.${ext}`; +const CACHE_NAME = 'gev-thumbs-v2'; +const VERSION = 'v2'; + +/* ───────────────────────── Projection ───────────────────────── */ + +function mercY(lat) { + const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * D2R); + return 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI); +} + +/** A Web Mercator view of `spanKm` across the card's shorter side. */ +export function makeView(lon, lat, spanKm, W, H) { + const mpp = (spanKm * 1000) / Math.min(W, H); + const zf = Math.max( + 0.6, + Math.min(19, Math.log2((156543.03392 * Math.cos(lat * D2R)) / mpp)), + ); + const scale = 256 * 2 ** zf; + const cx = ((lon + 180) / 360) * scale; + const cy = mercY(lat) * scale; + const view = { + lon, + lat, + W, + H, + zf, + scale, + cx, + cy, + /** Canvas px for lon/lat, wrapping longitude toward the centre. */ + project(lo, la) { + let x = ((lo + 180) / 360) * scale - cx; + if (x > scale / 2) x -= scale; + if (x < -scale / 2) x += scale; + return [x + W / 2, mercY(la) * scale - cy + H / 2]; + }, + unproject(x, y) { + const wx = x - W / 2 + cx; + const wy = y - H / 2 + cy; + const lo = (wx / scale) * 360 - 180; + const n = Math.PI - (2 * Math.PI * wy) / scale; + return [ + ((lo + 540) % 360) - 180, + (Math.atan(Math.sinh(n)) * 180) / Math.PI, + ]; + }, + /** Pixels per km at the view centre. */ + pxPerKm: Math.min(W, H) / spanKm, + }; + return view; +} + +/* ───────────────────────── Fetching ───────────────────────── */ + +const tileCache = new Map(); +const TILE_CACHE_MAX = 400; + +function loadImage(url, { cors = true } = {}) { + let p = tileCache.get(url); + if (p) { + tileCache.delete(url); + tileCache.set(url, p); + return p; + } + p = fetch(url, { mode: cors ? 'cors' : 'same-origin' }) + .then((r) => (r.ok ? r.blob() : null)) + .then((b) => (b && b.size > 0 ? createImageBitmap(b) : null)) + .catch(() => null); + tileCache.set(url, p); + while (tileCache.size > TILE_CACHE_MAX) + tileCache.delete(tileCache.keys().next().value); + return p; +} + +/** Draw slippy-map tiles for the view. Returns how many tiles drew. */ +async function drawTiles(g, view, urlFor, { maxZ = 18, minZ = 0 } = {}) { + const z = Math.max(minZ, Math.min(maxZ, Math.round(view.zf))); + const k = 2 ** (view.zf - z); + const n = 2 ** z; + const cxz = view.cx / k; + const cyz = view.cy / k; + const x0 = Math.floor((cxz - view.W / 2 / k) / 256); + const x1 = Math.floor((cxz + view.W / 2 / k) / 256); + const y0 = Math.max(0, Math.floor((cyz - view.H / 2 / k) / 256)); + const y1 = Math.min(n - 1, Math.floor((cyz + view.H / 2 / k) / 256)); + const jobs = []; + for (let ty = y0; ty <= y1; ty++) + for (let tx = x0; tx <= x1; tx++) { + const wx = ((tx % n) + n) % n; + jobs.push(loadImage(urlFor(z, wx, ty)).then((img) => ({ img, tx, ty }))); + } + let drawn = 0; + for (const { img, tx, ty } of await Promise.all(jobs)) { + if (!img) continue; + const dx = (tx * 256 - cxz) * k + view.W / 2; + const dy = (ty * 256 - cyz) * k + view.H / 2; + g.drawImage(img, dx, dy, 256 * k + 0.6, 256 * k + 0.6); + drawn++; + } + return drawn; +} + +/** Weather detail window (2:1, 0.25° grid) around the view, inside bounds. */ +function detailBbox(view, bounds, minWidthDeg = 1) { + const [w0, n0] = view.unproject(0, 0); + const [e0, s0] = view.unproject(view.W, view.H); + const lonSpan = (e0 - w0 + 360) % 360 || 360; + let width = Math.max(minWidthDeg, lonSpan * 1.05, (n0 - s0) * 2 * 1.05); + width = Math.ceil(width / 0.5) * 0.5; + const height = width / 2; + if (width > bounds.east - bounds.west || height > bounds.north - bounds.south) + return null; + const snap = (v) => Math.round(v / 0.25) * 0.25; + let west = snap(view.lon - width / 2); + let south = snap(view.lat - height / 2); + west = Math.max( + Math.ceil(bounds.west / 0.25) * 0.25, + Math.min(west, Math.floor((bounds.east - width) / 0.25) * 0.25), + ); + south = Math.max( + Math.ceil(bounds.south / 0.25) * 0.25, + Math.min(south, Math.floor((bounds.north - height) / 0.25) * 0.25), + ); + return { west, south, east: west + width, north: south + height }; +} + +async function weatherDetail(product, time, bbox, size = 1024) { + const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=${bbox.west},${bbox.south},${bbox.east},${bbox.north}&size=${size}x${size / 2}`; + return loadImage(url, { cors: false }); +} + +async function weatherWhole(product, time, width = 2048) { + const size = width === 4096 ? '' : `&size=${width}x${width / 2}`; + return loadImage( + `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${size}`, + { cors: false }, + ); +} + +/** A geographic (plate carrée) raster reprojected into the Mercator view. */ +function drawGeoRaster(g, view, src, b, { step = 2 } = {}) { + if (!src) return; + const sw = src.width; + const sh = src.height; + const [lonL] = view.unproject(0, 0); + const [lonR] = view.unproject(view.W, 0); + let left = lonL; + let right = lonR; + if (right <= left) right += 360; + const geoW = b.east - b.west; + const clampL = Math.max(left, b.west); + const clampR = Math.min(right, b.east); + if (clampR <= clampL) return; + const dx0 = ((clampL - left) / (right - left)) * view.W; + const dx1 = ((clampR - left) / (right - left)) * view.W; + const sx0 = ((clampL - b.west) / geoW) * sw; + const sx1 = ((clampR - b.west) / geoW) * sw; + for (let y = 0; y < view.H; y += step) { + const [, lat] = view.unproject(view.W / 2, y + step / 2); + if (lat > b.north || lat < b.south) continue; + const sy = ((b.north - lat) / (b.north - b.south)) * sh; + g.drawImage( + src, + sx0, + Math.max(0, sy - 0.5), + sx1 - sx0, + 1, + dx0, + y, + dx1 - dx0, + step + 0.3, + ); + } +} + +/** Pixels of an image (ImageBitmap/canvas) as RGBA. */ +function pixelsOf(img) { + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const g = c.getContext('2d', { willReadFrequently: true }); + g.drawImage(img, 0, 0); + return { canvas: c, g, data: g.getImageData(0, 0, c.width, c.height) }; +} + +/* ───────────────────────── Colour helpers ───────────────────────── */ + +const IR_STOPS = [ + [0, [0, 0, 0, 0]], + [120, [0, 0, 0, 0]], + [150, [170, 180, 195, 110]], + [172, [235, 240, 248, 200]], + [186, [90, 215, 255, 225]], + [198, [40, 110, 255, 235]], + [208, [60, 220, 130, 240]], + [218, [255, 228, 77, 245]], + [228, [255, 140, 50, 250]], + [236, [255, 45, 70, 255]], + [244, [255, 110, 235, 255]], + [252, [255, 255, 255, 255]], +]; + +/** Classic cold-cloud-top enhancement for an IR brightness value. */ +function irColor(l) { + for (let i = 1; i < IR_STOPS.length; i++) { + const [b, cb] = IR_STOPS[i]; + if (l <= b) { + const [a, ca] = IR_STOPS[i - 1]; + const t = (l - a) / Math.max(1, b - a); + return ca.map((v, k) => v + (cb[k] - v) * t); + } + } + return IR_STOPS.at(-1)[1]; +} + +function enhanceIr(img, { cool = false } = {}) { + const { canvas, g, data } = pixelsOf(img); + const px = data.data; + for (let i = 0; i < px.length; i += 4) { + const a = px[i + 3]; + if (a < 10) { + px[i + 3] = 0; + continue; + } + const c = cool + ? [150, 175, 215, Math.max(0, Math.min(220, (px[i] - 140) * 2))] + : irColor(px[i]); + px[i] = c[0]; + px[i + 1] = c[1]; + px[i + 2] = c[2]; + px[i + 3] = (c[3] * a) / 255; + } + g.putImageData(data, 0, 0); + return canvas; +} + +const SPEED_RAMP = [ + [0, [40, 90, 200]], + [15, [40, 190, 255]], + [35, [120, 255, 220]], + [55, [255, 255, 255]], + [75, [255, 120, 230]], + [100, [255, 60, 120]], +]; +function speedRgb(mps) { + for (let i = 1; i < SPEED_RAMP.length; i++) { + const [b, cb] = SPEED_RAMP[i]; + if (mps <= b) { + const [a, ca] = SPEED_RAMP[i - 1]; + const t = (mps - a) / (b - a); + return ca.map((v, k) => Math.round(v + (cb[k] - v) * t)); + } + } + return SPEED_RAMP.at(-1)[1]; +} + +const DBZ_RAMP = [ + [5, [70, 110, 255]], + [18, [40, 200, 255]], + [28, [60, 240, 130]], + [38, [255, 232, 70]], + [48, [255, 110, 50]], + [56, [255, 40, 90]], + [64, [255, 70, 230]], + [72, [255, 255, 255]], +]; +function dbzRgb(z) { + if (z <= DBZ_RAMP[0][0]) return DBZ_RAMP[0][1]; + for (let i = 1; i < DBZ_RAMP.length; i++) { + const [b, cb] = DBZ_RAMP[i]; + if (z <= b) { + const [a, ca] = DBZ_RAMP[i - 1]; + const t = (z - a) / (b - a); + return ca.map((v, k) => Math.round(v + (cb[k] - v) * t)); + } + } + return DBZ_RAMP.at(-1)[1]; +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function hashStr(s) { + let h = 2166136261; + for (const ch of String(s)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619); + return h >>> 0; +} + +const glowCache = new Map(); +/** A soft round sprite in a colour (for additive glows). */ +function glowSprite(css, size = 64) { + const key = `${css}:${size}`; + let c = glowCache.get(key); + if (c) return c; + c = document.createElement('canvas'); + c.width = c.height = size; + const g = c.getContext('2d'); + const r = g.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2, + ); + r.addColorStop(0, css); + r.addColorStop( + 0.25, + css.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.6})`), + ); + r.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = r; + g.fillRect(0, 0, size, size); + glowCache.set(key, c); + return c; +} + +/* ───────────────────────── Grade ───────────────────────── */ + +const BASE_FILTER = { + orbit: 'saturate(0.85) contrast(1.18) brightness(0.78)', + sky: 'saturate(0.55) contrast(1.15) brightness(0.68)', + ground: 'saturate(0.9) contrast(1.12) brightness(0.86) sepia(0.12)', + inside: 'saturate(0.9) contrast(1.1) brightness(0.9)', +}; +const BAND_TINT = { + orbit: '#6A4CFF', + sky: '#3F8CFF', + ground: '#FF9C42', + inside: '#FFC857', +}; + +function tint(g, W, H, band, alpha = 0.32) { + g.save(); + g.globalCompositeOperation = 'soft-light'; + g.globalAlpha = alpha; + g.fillStyle = BAND_TINT[band] || BAND_TINT.ground; + g.fillRect(0, 0, W, H); + g.restore(); +} + +function vignette(g, W, H, strength = 0.62) { + g.save(); + const r = g.createRadialGradient( + W / 2, + H * 0.46, + Math.min(W, H) * 0.32, + W / 2, + H / 2, + Math.hypot(W, H) * 0.62, + ); + r.addColorStop(0, 'rgba(0,0,0,0)'); + r.addColorStop(1, `rgba(2,4,8,${strength})`); + g.fillStyle = r; + g.fillRect(0, 0, W, H); + // A whisper of top/bottom falloff for the shell's text. + const lin = g.createLinearGradient(0, 0, 0, H); + lin.addColorStop(0, 'rgba(2,4,8,0.28)'); + lin.addColorStop(0.18, 'rgba(2,4,8,0)'); + lin.addColorStop(0.72, 'rgba(2,4,8,0)'); + lin.addColorStop(1, 'rgba(2,4,8,0.42)'); + g.fillStyle = lin; + g.fillRect(0, 0, W, H); + g.restore(); +} + +async function base(g, view, band, { night = false, filter } = {}) { + g.fillStyle = '#05070B'; + g.fillRect(0, 0, view.W, view.H); + g.save(); + g.filter = filter || BASE_FILTER[band] || 'none'; + const drawn = night + ? await drawTiles(g, view, BLACK_MARBLE_TILE, { maxZ: 8 }) + : await drawTiles(g, view, ESRI_TILE, { maxZ: 18 }); + g.restore(); + if (!drawn) throw new Error('base imagery unavailable'); +} + +/* ───────────────────────── Overlay primitives ───────────────────────── */ + +function pathLonLat(g, view, pts) { + let prev = null; + g.beginPath(); + for (const [lo, la] of pts) { + const [x, y] = view.project(lo, la); + if (!prev || Math.abs(x - prev[0]) > view.W * 1.5) g.moveTo(x, y); + else g.lineTo(x, y); + prev = [x, y]; + } +} + +function glowStroke(g, css, width, blur) { + g.save(); + g.shadowColor = css; + g.shadowBlur = blur; + g.strokeStyle = css; + g.lineWidth = width; + g.lineJoin = 'round'; + g.lineCap = 'round'; + g.stroke(); + g.restore(); +} + +function planeGlyph(g, x, y, headingDeg, size, css, { ring = false } = {}) { + g.save(); + g.translate(x, y); + g.rotate(headingDeg * D2R); + g.beginPath(); + const s = size / 24; + g.moveTo(0, -12 * s); + g.lineTo(2 * s, -5 * s); + g.lineTo(11 * s, 1 * s); + g.lineTo(11 * s, 3.5 * s); + g.lineTo(2 * s, 1 * s); + g.lineTo(1.6 * s, 7 * s); + g.lineTo(4.5 * s, 10 * s); + g.lineTo(4.5 * s, 11.5 * s); + g.lineTo(0, 10.2 * s); + g.lineTo(-4.5 * s, 11.5 * s); + g.lineTo(-4.5 * s, 10 * s); + g.lineTo(-1.6 * s, 7 * s); + g.lineTo(-2 * s, 1 * s); + g.lineTo(-11 * s, 3.5 * s); + g.lineTo(-11 * s, 1 * s); + g.lineTo(-2 * s, -5 * s); + g.closePath(); + g.shadowColor = css; + g.shadowBlur = size * 0.6; + g.fillStyle = css; + g.fill(); + g.restore(); + if (ring) { + for (const [r, a] of [ + [size * 1.3, 0.9], + [size * 2.2, 0.5], + [size * 3.3, 0.25], + ]) { + g.beginPath(); + g.arc(x, y, r, 0, Math.PI * 2); + g.strokeStyle = css.replace(/[\d.]+\)$/, `${a})`); + g.lineWidth = size * 0.09; + g.stroke(); + } + } +} + +function heliGlyph(g, x, y, size, css) { + g.save(); + g.translate(x, y); + g.shadowColor = css; + g.shadowBlur = size * 0.6; + g.strokeStyle = css; + g.fillStyle = css; + g.lineWidth = size * 0.1; + g.beginPath(); + g.arc(0, 0, size * 0.22, 0, Math.PI * 2); + g.fill(); + g.beginPath(); + g.moveTo(-size * 0.55, -size * 0.55); + g.lineTo(size * 0.55, size * 0.55); + g.moveTo(size * 0.55, -size * 0.55); + g.lineTo(-size * 0.55, size * 0.55); + g.stroke(); + g.restore(); +} + +/** Destination point (deg) from lon/lat along a bearing for km. */ +function dest(lon, lat, bearingDeg, km) { + const d = km / 6371; + const b = bearingDeg * D2R; + const p1 = lat * D2R; + const l1 = lon * D2R; + const p2 = Math.asin( + Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b), + ); + const l2 = + l1 + + Math.atan2( + Math.sin(b) * Math.sin(d) * Math.cos(p1), + Math.cos(d) - Math.sin(p1) * Math.sin(p2), + ); + return [((l2 / D2R + 540) % 360) - 180, p2 / D2R]; +} + +function circleLonLat(lon, lat, km, step = 4) { + const pts = []; + for (let a = 0; a <= 360; a += step) pts.push(dest(lon, lat, a, km)); + return pts; +} + +/* ───────────────────────── Scenes ───────────────────────── */ + +const SCENE_DRAW = { + async cyclone(g, view, sc, dpr) { + await base(g, view, 'orbit', { + filter: 'saturate(0.6) contrast(1.2) brightness(0.5)', + }); + tint(g, view.W, view.H, 'orbit', 0.28); + // IR: a detail window when it fits, else the global mosaic. + if (sc.irTime && sc.irBounds) { + const bb = detailBbox(view, sc.irBounds, 4); + const img = bb + ? await weatherDetail('clouds', sc.irTime, bb, 1024) + : await weatherWhole('clouds', sc.irTime, 2048); + if (img) drawGeoRaster(g, view, enhanceIr(img), bb || sc.irBounds); + } + const s = sc.storm; + const lw = 1.6 * dpr; + // Cone of uncertainty. + const cone = + s.cone?.type === 'Polygon' + ? s.cone.coordinates[0] + : s.cone?.type === 'MultiPolygon' + ? s.cone.coordinates[0][0] + : null; + if (cone) { + pathLonLat(g, view, cone); + g.closePath(); + g.fillStyle = 'rgba(255,255,255,0.13)'; + g.fill(); + g.setLineDash([6 * dpr, 5 * dpr]); + glowStroke(g, 'rgba(255,255,255,0.85)', lw, 6 * dpr); + g.setLineDash([]); + } + // Wind radii (34/50/64 kt quadrants). + for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) { + if (!r.quadrantsKm.some((k) => k > 0)) continue; + pathLonLat(g, view, radiiRing(s.lon, s.lat, r.quadrantsKm, 4)); + g.closePath(); + const col = RADII_COLORS[r.kt] || '#FFD84D'; + g.globalAlpha = r.kt === 64 ? 0.42 : r.kt === 50 ? 0.32 : 0.22; + g.fillStyle = col; + g.fill(); + g.globalAlpha = 0.9; + g.strokeStyle = col; + g.lineWidth = 1.2 * dpr; + g.stroke(); + g.globalAlpha = 1; + } + // Past track (EONET) and forecast track with intensity dots. + if (s.pastTrack?.length > 1) { + pathLonLat( + g, + view, + s.pastTrack.map((p) => [p.lon, p.lat]), + ); + glowStroke(g, 'rgba(255,255,255,0.8)', lw, 4 * dpr); + } + const fc = [{ lon: s.lon, lat: s.lat, windKt: s.windKt }, ...s.forecast]; + if (fc.length > 1) { + pathLonLat( + g, + view, + fc.map((p) => [p.lon, p.lat]), + ); + glowStroke(g, 'rgba(255,255,255,0.95)', lw * 1.2, 8 * dpr); + for (const p of fc.slice(1)) { + const [x, y] = view.project(p.lon, p.lat); + const kt = p.windKt || 0; + const col = + kt >= 96 + ? '#FF4FD8' + : kt >= 64 + ? '#FF4D4D' + : kt >= 34 + ? '#FFD84D' + : '#7CC4FF'; + g.beginPath(); + g.arc(x, y, (3 + Math.min(5, kt / 25)) * dpr, 0, Math.PI * 2); + g.fillStyle = col; + g.shadowColor = col; + g.shadowBlur = 8 * dpr; + g.fill(); + g.shadowBlur = 0; + g.lineWidth = 1 * dpr; + g.strokeStyle = 'rgba(0,0,0,0.6)'; + g.stroke(); + } + } + // The storm symbol. + const [cx, cy] = view.project(s.lon, s.lat); + g.save(); + g.translate(cx, cy); + g.strokeStyle = '#fff'; + g.shadowColor = '#fff'; + g.shadowBlur = 10 * dpr; + g.lineWidth = 2.6 * dpr; + const R = 11 * dpr; + g.beginPath(); + g.arc(0, 0, R * 0.45, 0, Math.PI * 2); + g.stroke(); + for (const sgn of [1, -1]) { + g.beginPath(); + g.arc( + sgn * R * 0.45, + 0, + R, + sgn > 0 ? Math.PI : 0, + sgn > 0 ? Math.PI * 1.55 : Math.PI * 0.55, + ); + g.stroke(); + } + g.restore(); + vignette(g, view.W, view.H, 0.55); + }, + + async lightning(g, view, sc, dpr) { + await base(g, view, 'sky', { + filter: 'saturate(0.45) contrast(1.15) brightness(0.45)', + }); + tint(g, view.W, view.H, 'sky', 0.4); + if (sc.irTime && sc.irBounds) { + const bb = detailBbox(view, sc.irBounds, 2); + const img = bb + ? await weatherDetail('clouds', sc.irTime, bb, 1024) + : null; + if (img) { + g.globalAlpha = 0.32; + drawGeoRaster(g, view, enhanceIr(img, { cool: true }), bb); + g.globalAlpha = 1; + } + } + if (sc.radar) { + const bb = detailBbox(view, sc.radar.bounds, 1); + const img = bb + ? await weatherDetail('radar', sc.radar.time, bb, 1024) + : null; + if (img) { + g.globalAlpha = 0.75; + drawGeoRaster(g, view, img, bb); + g.globalAlpha = 1; + } + } + // Strikes by age: oldest violet and faint → newest white-hot. + const bb = detailBbox(view, sc.bounds, 1); + const times = sc.times.slice(-6); + const frames = await Promise.all( + times.map((t) => + bb + ? weatherDetail('lightning', t, bb, 1024) + : weatherWhole('lightning', t, 2048), + ), + ); + const box = bb || sc.bounds; + const colors = [ + 'rgba(255,250,225,1)', + 'rgba(255,214,120,0.85)', + 'rgba(255,150,80,0.7)', + 'rgba(240,90,160,0.55)', + 'rgba(170,90,255,0.45)', + 'rgba(110,90,255,0.35)', + ]; + // Sample at the product's native ~8 km grid, one dot per cell. + const kmPerPx = + ((box.east - box.west) * 111.32 * Math.cos(view.lat * D2R)) / 1024; + const step = Math.max(1, Math.round(8 / kmPerPx)); + const spacing = 8 * view.pxPerKm; + const dotR = Math.max(1.4 * dpr, Math.min(4.5 * dpr, spacing * 0.42)); + g.save(); + frames.forEach((img, i) => { + if (!img) return; + const age = frames.length - 1 - i; // 0 = newest + g.globalCompositeOperation = age === 0 ? 'lighter' : 'source-over'; + const { data } = pixelsOf(img); + const px = data.data; + const w = img.width; + const h = img.height; + const sprite = glowSprite(colors[Math.min(colors.length - 1, age)], 48); + const jitter = mulberry32(hashStr(times[i])); + for (let y = 0; y < h; y += step) + for (let x = 0; x < w; x += step) { + const k = (y * w + x) * 4; + if (px[k + 3] < 40) continue; + const lon = + box.west + ((x + jitter() * step) / w) * (box.east - box.west); + const lat = + box.north - ((y + jitter() * step) / h) * (box.north - box.south); + const [sx, sy] = view.project(lon, lat); + if (sx < -20 || sy < -20 || sx > view.W + 20 || sy > view.H + 20) + continue; + const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]); + const r = + dotR * (age === 0 ? 1.9 : 1.1 - age * 0.08) * (0.7 + lvl * 0.3); + g.drawImage(sprite, sx - r, sy - r, r * 2, r * 2); + } + }); + g.restore(); + vignette(g, view.W, view.H, 0.6); + }, + + async radar(g, view, sc, dpr) { + await base(g, view, 'sky'); + tint(g, view.W, view.H, 'sky', 0.38); + const bb = detailBbox(view, sc.bounds, 1); + const img = bb + ? await weatherDetail('radar', sc.time, bb, 1024) + : await weatherWhole('radar', sc.time, 4096); + if (img) { + // A soft bloom under crisp echoes; heavier echoes glow hotter. + const { canvas, g: rg, data } = pixelsOf(img); + const px = data.data; + for (let i = 0; i < px.length; i += 4) { + const z = dbzOf(px[i], px[i + 1], px[i + 2], px[i + 3]); + if (z == null) { + px[i + 3] = 0; + continue; + } + const [r, gg, b] = dbzRgb(z); + px[i] = r; + px[i + 1] = gg; + px[i + 2] = b; + px[i + 3] = Math.min(255, 110 + z * 2.8); + } + rg.putImageData(data, 0, 0); + g.save(); + g.filter = `blur(${4 * dpr}px)`; + g.globalAlpha = 0.55; + drawGeoRaster(g, view, canvas, bb || sc.bounds); + g.restore(); + g.globalAlpha = 0.92; + drawGeoRaster(g, view, canvas, bb || sc.bounds, { step: 1 }); + g.globalAlpha = 1; + } + // City reticle: a hairline 45 km radius ring (the analysis radius). + pathLonLat(g, view, circleLonLat(sc.lon, sc.lat, 45)); + g.setLineDash([3 * dpr, 4 * dpr]); + g.strokeStyle = 'rgba(200,230,255,0.55)'; + g.lineWidth = 1 * dpr; + g.stroke(); + g.setLineDash([]); + vignette(g, view.W, view.H); + }, + + async jet(g, view, sc, dpr, feeds) { + await base(g, view, 'orbit', { + night: true, + filter: 'brightness(1.1) contrast(1.15) saturate(0.8)', + }); + tint(g, view.W, view.H, 'sky', 0.3); + const field = feeds.jet.data?.field; + if (!field) throw new Error('jet grid unavailable'); + const rnd = mulberry32(7); + const spacing = 11 * dpr; + g.save(); + g.globalCompositeOperation = 'lighter'; + g.lineCap = 'round'; + for (let y0 = spacing / 2; y0 < view.H; y0 += spacing) + for (let x0 = spacing / 2; x0 < view.W; x0 += spacing) { + let x = x0 + (rnd() - 0.5) * spacing; + let y = y0 + (rnd() - 0.5) * spacing; + let [lon, lat] = view.unproject(x, y); + if (Math.abs(lat) > 68) continue; // beyond the grid: no data + let { u, v } = sampleField(field, lon, lat); + let sp = Math.hypot(u, v); + if (sp < 12) continue; + const [r, gg, b] = speedRgb(sp); + const steps = 10 + Math.round(sp / 6); + g.beginPath(); + g.moveTo(x, y); + for (let k = 0; k < steps; k++) { + // Pixel step along the wind, normalised so long streaks = fast. + const dpx = (sp / 40) * 2.2 * dpr; + const norm = Math.hypot(u, v) || 1; + x += (u / norm) * dpx; + y -= (v / norm) * dpx; + g.lineTo(x, y); + [lon, lat] = view.unproject(x, y); + ({ u, v } = sampleField(field, lon, lat)); + sp = Math.hypot(u, v); + } + const a = Math.min(0.85, 0.15 + sp / 90); + g.strokeStyle = `rgba(${r},${gg},${b},${a})`; + g.lineWidth = (0.6 + Math.min(1.6, sp / 40)) * dpr; + g.stroke(); + } + g.restore(); + vignette(g, view.W, view.H, 0.5); + }, + + async quake(g, view, sc, dpr) { + await base(g, view, 'ground'); + tint(g, view.W, view.H, 'ground', 0.3); + const q = sc.quake; + const [cx, cy] = view.project(q.lon, q.lat); + // Shaking falls off with distance: rings at 10–200 km, hotter inside. + const rings = [ + [200, 'rgba(255,214,90,0.30)'], + [120, 'rgba(255,170,60,0.38)'], + [70, 'rgba(255,120,50,0.48)'], + [35, 'rgba(255,70,50,0.6)'], + [15, 'rgba(255,40,60,0.75)'], + ]; + const grad = g.createRadialGradient( + cx, + cy, + 0, + cx, + cy, + view.pxPerKm * 60 * Math.max(1, q.mag - 3), + ); + grad.addColorStop(0, 'rgba(255,60,50,0.45)'); + grad.addColorStop(1, 'rgba(255,60,50,0)'); + g.fillStyle = grad; + g.fillRect(0, 0, view.W, view.H); + for (const [km, css] of rings) { + pathLonLat(g, view, circleLonLat(q.lon, q.lat, km)); + glowStroke(g, css, 1.6 * dpr, 6 * dpr); + } + for (const n of sc.nearby || []) { + const [x, y] = view.project(n.lon, n.lat); + g.beginPath(); + g.arc(x, y, (1.5 + Math.max(0, n.mag - 2.5) * 1.6) * dpr, 0, Math.PI * 2); + g.strokeStyle = 'rgba(255,220,150,0.85)'; + g.lineWidth = 1.2 * dpr; + g.stroke(); + } + g.drawImage( + glowSprite('rgba(255,250,240,1)', 64), + cx - 14 * dpr, + cy - 14 * dpr, + 28 * dpr, + 28 * dpr, + ); + g.beginPath(); + g.moveTo(cx - 18 * dpr, cy); + g.lineTo(cx + 18 * dpr, cy); + g.moveTo(cx, cy - 18 * dpr); + g.lineTo(cx, cy + 18 * dpr); + g.strokeStyle = 'rgba(255,255,255,0.8)'; + g.lineWidth = 1 * dpr; + g.stroke(); + vignette(g, view.W, view.H); + }, + + async 'quake-world'(g, view, sc, dpr) { + await base(g, view, 'ground', { + filter: 'saturate(0.35) contrast(1.25) brightness(0.42)', + }); + tint(g, view.W, view.H, 'ground', 0.4); + const now = Date.now(); + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const q of sc.quakes.slice().sort((a, b) => a.mag - b.mag)) { + const [x, y] = view.project(q.lon, q.lat); + if (x < -30 || y < -30 || x > view.W + 30 || y > view.H + 30) continue; + const fresh = Math.max(0.25, 1 - (now - q.time) / 86_400_000); + const r = (6 + Math.max(0, q.mag - 2.5) * 9) * dpr; + const col = + q.mag >= 5 + ? `rgba(255,70,60,${fresh})` + : q.mag >= 4 + ? `rgba(255,150,60,${fresh})` + : `rgba(255,215,120,${fresh})`; + g.drawImage(glowSprite(col, 64), x - r, y - r, r * 2, r * 2); + if (q.mag >= 4.5) { + g.beginPath(); + g.arc(x, y, r * 0.9, 0, Math.PI * 2); + g.strokeStyle = col; + g.lineWidth = 1.2 * dpr; + g.stroke(); + } + } + g.restore(); + vignette(g, view.W, view.H, 0.5); + }, + + async fire(g, view, sc, dpr) { + await base(g, view, 'ground'); + tint(g, view.W, view.H, 'ground', 0.36); + const rnd = mulberry32(hashStr(`${sc.lon},${sc.lat}`)); + const f0 = sc.fires[0] || { lon: sc.lon, lat: sc.lat }; + if (sc.wind) { + // Smoke plume: downwind of the fire for ~2 h of travel at the + // reported wind, widening with distance (direction is real; the + // plume's texture is drawn). + const lenKm = Math.min( + view.W / view.pxPerKm, + Math.max(12, sc.wind.speedKmh * 2), + ); + g.save(); + for (let i = 0; i < 70; i++) { + const t = i / 70; + const wobble = Math.sin(t * 7 + rnd() * 2) * 6 * t; + const [lon, lat] = dest( + f0.lon, + f0.lat, + sc.wind.toDeg + wobble, + lenKm * t, + ); + const [x, y] = view.project(lon, lat); + const r = (6 + t * lenKm * 0.35 * view.pxPerKm) * (0.7 + rnd() * 0.6); + const a = 0.11 * (1 - t) + 0.025; + const grad = g.createRadialGradient(x, y, 0, x, y, r); + grad.addColorStop(0, `rgba(215,205,190,${a})`); + grad.addColorStop(1, 'rgba(215,205,190,0)'); + g.fillStyle = grad; + g.fillRect(x - r, y - r, r * 2, r * 2); + } + g.restore(); + } + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const f of sc.fires) { + const [x, y] = view.project(f.lon, f.lat); + const big = (f.frp ?? 0) > 50 || (f.acres ?? 0) > 5000; + const r = (big ? 26 : 16) * dpr; + g.drawImage( + glowSprite('rgba(255,110,30,1)', 64), + x - r, + y - r, + r * 2, + r * 2, + ); + g.drawImage( + glowSprite('rgba(255,230,160,1)', 64), + x - r / 3, + y - r / 3, + (r * 2) / 3, + (r * 2) / 3, + ); + } + g.restore(); + vignette(g, view.W, view.H); + }, + + async aircraft(g, view, sc, dpr) { + await base(g, view, 'sky'); + tint(g, view.W, view.H, 'sky', 0.4); + if (sc.airport) { + const [x, y] = view.project(sc.airport.lon, sc.airport.lat); + for (const km of [10, 25, 50]) { + g.beginPath(); + g.arc(x, y, km * view.pxPerKm, 0, Math.PI * 2); + g.strokeStyle = 'rgba(160,210,255,0.35)'; + g.setLineDash([2 * dpr, 5 * dpr]); + g.lineWidth = 1 * dpr; + g.stroke(); + } + g.setLineDash([]); + } + const list = sc.aircraft || []; + const glyph = + Math.max(9, Math.min(16, 4200 / Math.max(60, view.W / view.pxPerKm))) * + dpr; + for (const a of list) { + if (!a.speed || a.onGround) continue; + const [x, y] = view.project(a.lon, a.lat); + // Velocity leader: where the aircraft will be in 2 minutes. + const [lo, la] = dest( + a.lon, + a.lat, + a.heading || 0, + (a.speed * 120) / 1000, + ); + const [x2, y2] = view.project(lo, la); + g.beginPath(); + g.moveTo(x, y); + g.lineTo(x2, y2); + g.strokeStyle = a.emergency + ? 'rgba(255,80,90,0.9)' + : 'rgba(140,210,255,0.55)'; + g.lineWidth = 1.1 * dpr; + g.stroke(); + } + for (const a of list) { + const [x, y] = view.project(a.lon, a.lat); + if (x < -20 || y < -20 || x > view.W + 20 || y > view.H + 20) continue; + const css = a.military + ? 'rgba(255,209,102,1)' + : a.helicopter + ? 'rgba(155,225,93,1)' + : a.onGround + ? 'rgba(160,190,220,0.6)' + : 'rgba(190,230,255,1)'; + if (a.helicopter) heliGlyph(g, x, y, glyph * 1.1, css); + else planeGlyph(g, x, y, a.heading || 0, glyph, css); + } + if (sc.focus) { + const [x, y] = view.project(sc.focus.lon, sc.focus.lat); + const [lo, la] = dest( + sc.focus.lon, + sc.focus.lat, + sc.focus.heading || 0, + ((sc.focus.speed || 0) * 600) / 1000, + ); + const [x2, y2] = view.project(lo, la); + g.beginPath(); + g.moveTo(x, y); + g.lineTo(x2, y2); + g.setLineDash([5 * dpr, 4 * dpr]); + glowStroke(g, 'rgba(255,70,80,0.95)', 2 * dpr, 8 * dpr); + g.setLineDash([]); + planeGlyph( + g, + x, + y, + sc.focus.heading || 0, + 26 * dpr, + 'rgba(255,72,82,1)', + { ring: true }, + ); + } + vignette(g, view.W, view.H); + }, + + async orbit(g, view, sc, dpr) { + await base(g, view, 'orbit', { + night: true, + filter: 'brightness(1.15) contrast(1.2) saturate(0.9)', + }); + tint(g, view.W, view.H, 'orbit', 0.34); + const at = sc.at || Date.now(); + const sats = sc.sats.map((s) => ({ + ...s, + rec: twoline2satrec(s.line1, s.line2), + })); + const main = sats.find((s) => s.main) || sats[0]; + // Ground track of the main satellite: −50 → +50 min. + const track = []; + for (let m = -50; m <= 50; m += 1) { + const p = satPosition(main.rec, at + m * 60_000); + if (p) track.push([p.lon, p.lat, m]); + } + const past = track.filter((p) => p[2] <= 0); + const future = track.filter((p) => p[2] >= 0); + pathLonLat(g, view, past); + glowStroke(g, 'rgba(201,167,255,0.85)', 2 * dpr, 10 * dpr); + pathLonLat(g, view, future); + g.setLineDash([6 * dpr, 6 * dpr]); + glowStroke(g, 'rgba(201,167,255,0.6)', 1.6 * dpr, 6 * dpr); + g.setLineDash([]); + if (sc.train) { + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const s of sats) { + const p = satPosition(s.rec, at); + if (!p) continue; + const [x, y] = view.project(p.lon, p.lat); + const r = 7 * dpr; + g.drawImage( + glowSprite('rgba(235,225,255,1)', 48), + x - r, + y - r, + r * 2, + r * 2, + ); + } + g.restore(); + } + const p = satPosition(main.rec, at); + if (p) { + const [x, y] = view.project(p.lon, p.lat); + // Visibility footprint (horizon circle). + const footKm = Math.acos(6371 / (6371 + p.altM / 1000)) * 6371; + pathLonLat(g, view, circleLonLat(p.lon, p.lat, footKm, 3)); + g.fillStyle = 'rgba(160,120,255,0.08)'; + g.fill(); + g.setLineDash([3 * dpr, 5 * dpr]); + g.strokeStyle = 'rgba(201,167,255,0.6)'; + g.lineWidth = 1 * dpr; + g.stroke(); + g.setLineDash([]); + if (!sc.train) { + g.drawImage( + glowSprite('rgba(255,255,255,1)', 64), + x - 18 * dpr, + y - 18 * dpr, + 36 * dpr, + 36 * dpr, + ); + g.save(); + g.translate(x, y); + g.fillStyle = '#fff'; + g.fillRect(-11 * dpr, -2.2 * dpr, 22 * dpr, 4.4 * dpr); + g.fillRect(-1.6 * dpr, -6 * dpr, 3.2 * dpr, 12 * dpr); + g.restore(); + } + } + if (sc.observer) { + const [x, y] = view.project(sc.observer.lon, sc.observer.lat); + g.beginPath(); + g.arc(x, y, 4 * dpr, 0, Math.PI * 2); + g.fillStyle = '#FFC857'; + g.fill(); + } + vignette(g, view.W, view.H, 0.45); + }, + + async constellation(g, view, sc, dpr) { + await base(g, view, 'orbit', { + night: true, + filter: 'brightness(1.1) contrast(1.2) saturate(0.85)', + }); + tint(g, view.W, view.H, 'orbit', 0.34); + const at = sc.at || Date.now(); + g.save(); + g.globalCompositeOperation = 'lighter'; + const dot = glowSprite('rgba(220,205,255,0.95)', 32); + const head = glowSprite('rgba(255,255,255,1)', 32); + for (const s of sc.sats) { + let rec; + try { + rec = twoline2satrec(s.line1, s.line2); + } catch { + continue; + } + const p = satPosition(rec, at); + if (!p) continue; + const [x, y] = view.project(p.lon, p.lat); + if (x < -10 || y < -10 || x > view.W + 10 || y > view.H + 10) continue; + // A 90-second streak behind each satellite shows the shells' motion. + const q = satPosition(rec, at - 90_000); + if (q) { + const [x0, y0] = view.project(q.lon, q.lat); + if (Math.abs(x0 - x) < view.W / 3) { + g.beginPath(); + g.moveTo(x0, y0); + g.lineTo(x, y); + g.strokeStyle = 'rgba(170,140,255,0.35)'; + g.lineWidth = 0.8 * dpr; + g.stroke(); + } + } + const r = 2.6 * dpr; + g.drawImage( + hashStr(s.line1) % 50 === 0 ? head : dot, + x - r, + y - r, + r * 2, + r * 2, + ); + } + g.restore(); + vignette(g, view.W, view.H, 0.45); + }, + + async launch(g, view, sc, dpr) { + await base(g, view, 'orbit', { + filter: 'saturate(0.7) contrast(1.15) brightness(0.62)', + }); + tint(g, view.W, view.H, 'orbit', 0.42); + const pad = sc.pad; + const orbit = String(sc.orbit || '').toUpperCase(); + // Indicative ascent azimuth from the pad and target orbit. + let az = 90; + if (/SSO|POLAR|PO\b/.test(orbit)) + az = pad.lat > 0 ? (pad.lon < -100 ? 190 : 160) : 200; + else if (/ISS|LEO/.test(orbit) && pad.lon > -82 && pad.lon < -79) az = 44; + else if (/GTO|GEO|MEO|HEO/.test(orbit)) az = 95; + const pts = []; + for (let km = 0; km <= 600; km += 10) { + const bend = Math.min(1, km / 600) ** 2 * 6; + pts.push(dest(pad.lon, pad.lat, az + bend, km)); + } + pathLonLat(g, view, pts); + glowStroke(g, 'rgba(255,240,255,0.95)', 2.4 * dpr, 14 * dpr); + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const km of [100, 200, 300, 400]) { + const [x, y] = view.project( + ...dest(pad.lon, pad.lat, az + (km / 600) ** 2 * 6, km), + ); + g.drawImage( + glowSprite('rgba(201,167,255,1)', 48), + x - 8 * dpr, + y - 8 * dpr, + 16 * dpr, + 16 * dpr, + ); + } + const [px, py] = view.project(pad.lon, pad.lat); + g.drawImage( + glowSprite('rgba(255,200,120,1)', 64), + px - 22 * dpr, + py - 22 * dpr, + 44 * dpr, + 44 * dpr, + ); + g.restore(); + vignette(g, view.W, view.H); + }, + + async gnss(g, view, sc, dpr) { + await base(g, view, 'sky', { + filter: 'saturate(0.3) contrast(1.2) brightness(0.42)', + }); + tint(g, view.W, view.H, 'sky', 0.35); + // Hex heat: percentage of aircraft with degraded integrity per hex. + const R = 26 * dpr; + const hexW = Math.sqrt(3) * R; + const bins = new Map(); + for (const r of sc.rows) { + const [x, y] = view.project(r.lon, r.lat); + const row = Math.round(y / (1.5 * R)); + const col = Math.round((x - (row % 2) * (hexW / 2)) / hexW); + const key = `${row}:${col}`; + let b = bins.get(key); + if (!b) bins.set(key, (b = { row, col, n: 0, bad: 0 })); + b.n++; + if (r.degraded || r.gpsLost) b.bad++; + } + for (const b of bins.values()) { + if (b.n < 2) continue; + const frac = Math.max(0, b.bad - 1) / b.n; + const x = b.col * hexW + (b.row % 2) * (hexW / 2); + const y = b.row * 1.5 * R; + const col = + frac >= 0.1 + ? [255, 59, 107] + : frac >= 0.02 + ? [255, 177, 61] + : [74, 222, 128]; + g.beginPath(); + for (let k = 0; k < 6; k++) { + const a = (Math.PI / 3) * k + Math.PI / 6; + g.lineTo(x + R * 0.94 * Math.cos(a), y + R * 0.94 * Math.sin(a)); + } + g.closePath(); + g.fillStyle = `rgba(${col},${0.18 + Math.min(0.5, frac * 1.2)})`; + g.fill(); + g.strokeStyle = `rgba(${col},0.6)`; + g.lineWidth = 1 * dpr; + g.stroke(); + } + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const r of sc.rows) { + const [x, y] = view.project(r.lon, r.lat); + const bad = r.degraded || r.gpsLost; + const rr = (bad ? 6 : 3.5) * dpr; + g.drawImage( + glowSprite(bad ? 'rgba(255,70,130,1)' : 'rgba(140,220,255,0.9)', 32), + x - rr, + y - rr, + rr * 2, + rr * 2, + ); + } + g.restore(); + vignette(g, view.W, view.H); + }, + + async transit(g, view, sc, dpr) { + await base(g, view, 'ground', { + filter: 'saturate(0.7) contrast(1.15) brightness(0.62) sepia(0.1)', + }); + tint(g, view.W, view.H, 'ground', 0.34); + g.save(); + g.globalCompositeOperation = 'lighter'; + for (const v of sc.vehicles) { + const [x, y] = view.project(v.lon, v.lat); + if (x < -10 || y < -10 || x > view.W + 10 || y > view.H + 10) continue; + const h = hashStr(v.routeId) % 360; + const moving = (v.speedMps ?? 0) > 1; + const r = (moving ? 7 : 5) * dpr; + g.drawImage( + glowSprite(`hsla(${h},90%,65%,1)`, 32), + x - r, + y - r, + r * 2, + r * 2, + ); + if (moving && v.bearing != null) { + const [x2, y2] = view.project( + ...dest(v.lon, v.lat, v.bearing, (v.speedMps * 60) / 1000), + ); + g.beginPath(); + g.moveTo(x, y); + g.lineTo(x2, y2); + g.strokeStyle = `hsla(${h},90%,70%,0.8)`; + g.lineWidth = 1.4 * dpr; + g.stroke(); + } + } + g.restore(); + vignette(g, view.W, view.H); + }, + + async aq(g, view, sc, dpr) { + // Yesterday's VIIRS true colour (dust and smoke are visible in it) with + // MODIS aerosol optical depth over it: observed aerosol, not the model. + const day = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + g.fillStyle = '#05070B'; + g.fillRect(0, 0, view.W, view.H); + g.save(); + g.filter = 'contrast(1.12) saturate(1.05) brightness(0.92)'; + const drawn = await drawTiles( + g, + view, + GIBS_TILE('VIIRS_NOAA20_CorrectedReflectance_TrueColor', day, 'jpg', 9), + { maxZ: 9 }, + ); + g.restore(); + if (!drawn) await base(g, view, 'ground'); + tint(g, view.W, view.H, 'ground', 0.22); + const aod = document.createElement('canvas'); + aod.width = view.W; + aod.height = view.H; + const ag = aod.getContext('2d'); + if ( + await drawTiles( + ag, + view, + GIBS_TILE('MODIS_Terra_Aerosol_Optical_Depth_3km', day, 'png', 6), + { maxZ: 6 }, + ) + ) { + g.save(); + g.globalAlpha = 0.5; + g.filter = `blur(${2 * dpr}px)`; + g.drawImage(aod, 0, 0); + g.restore(); + } + const [x, y] = view.project(sc.lon, sc.lat); + const css = aqiColor(sc.aqi); + for (const [r, a] of [ + [10, 1], + [22, 0.55], + [38, 0.25], + ]) { + g.beginPath(); + g.arc(x, y, r * dpr, 0, Math.PI * 2); + g.globalAlpha = a; + g.strokeStyle = css; + g.lineWidth = 1.6 * dpr; + g.stroke(); + } + g.globalAlpha = 1; + g.drawImage( + glowSprite('rgba(255,255,255,1)', 32), + x - 5 * dpr, + y - 5 * dpr, + 10 * dpr, + 10 * dpr, + ); + vignette(g, view.W, view.H); + }, + + async volcano(g, view, sc, dpr) { + await base(g, view, 'ground'); + tint(g, view.W, view.H, 'ground', 0.36); + const [x, y] = view.project(sc.lon, sc.lat); + g.save(); + g.globalCompositeOperation = 'lighter'; + g.drawImage( + glowSprite('rgba(255,80,30,0.9)', 64), + x - 40 * dpr, + y - 40 * dpr, + 80 * dpr, + 80 * dpr, + ); + g.drawImage( + glowSprite('rgba(255,220,150,1)', 64), + x - 10 * dpr, + y - 10 * dpr, + 20 * dpr, + 20 * dpr, + ); + g.restore(); + vignette(g, view.W, view.H); + }, + + async flood(g, view, sc, dpr) { + await base(g, view, 'ground'); + tint(g, view.W, view.H, 'ground', 0.3); + if (sc.path?.length > 1) { + pathLonLat(g, view, sc.path); + glowStroke(g, 'rgba(94,200,255,0.9)', 3 * dpr, 12 * dpr); + } + if (sc.corridor?.length > 1) { + pathLonLat( + g, + view, + sc.corridor.map((c) => [c[0], c[1]]), + ); + glowStroke(g, 'rgba(190,240,255,1)', 4 * dpr, 16 * dpr); + } + for (const a of sc.anchors || []) { + const [x, y] = view.project(a.lon, a.lat); + g.drawImage( + glowSprite('rgba(255,200,87,1)', 32), + x - 8 * dpr, + y - 8 * dpr, + 16 * dpr, + 16 * dpr, + ); + } + vignette(g, view.W, view.H); + }, + + async vessels(g, view, sc, dpr) { + await base(g, view, 'ground', { + filter: 'saturate(0.6) contrast(1.15) brightness(0.6)', + }); + tint(g, view.W, view.H, 'ground', 0.25); + for (const v of sc.vessels) { + const [x, y] = view.project(v.lon, v.lat); + planeGlyph(g, x, y, v.heading || 0, 8 * dpr, 'rgba(56,225,198,1)'); + } + vignette(g, view.W, view.H); + }, + + async eye(g, view, sc, dpr, feeds) { + let blob = EYE_FRAMES.get(sc.cameraId); + if (!blob || Date.now() - blob.at > 60_000) { + const cam = feeds.cctv.data?.records.find((c) => c.id === sc.cameraId); + if (!cam) throw new Error('camera not in catalog'); + const r = await fetch(cctvFrameUrl(cam), { cache: 'no-store' }); + if (!r.ok || r.headers.get('x-cctv-source') !== 'upstream-image') + throw new Error('no live frame'); + blob = { blob: await r.blob(), at: Date.now() }; + EYE_FRAMES.set(sc.cameraId, blob); + } + const img = await createImageBitmap(blob.blob); + const { W, H } = view; + const s = Math.max(W / img.width, H / img.height); + const w = img.width * s; + const h = img.height * s; + g.fillStyle = '#05070B'; + g.fillRect(0, 0, W, H); + g.save(); + g.filter = 'contrast(1.12) saturate(1.05) brightness(0.95)'; + g.drawImage(img, (W - w) / 2, (H - h) / 2, w, h); + g.restore(); + img.close?.(); + tint(g, W, H, 'ground', 0.18); + vignette(g, W, H, 0.5); + }, + + async plain(g, view, sc) { + await base(g, view, sc.band || 'ground'); + tint(g, view.W, view.H, sc.band || 'ground'); + vignette(g, view.W, view.H); + }, +}; + +/* ───────────────────────── Compositor ───────────────────────── */ + +function semaphore(n) { + let active = 0; + const queue = []; + const next = () => { + if (active >= n || !queue.length) return; + active++; + const { fn, resolve, reject } = queue.shift(); + fn() + .then(resolve, reject) + .finally(() => { + active--; + next(); + }); + }; + return (fn) => + new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + next(); + }); +} + +export function createCompositor({ feeds }) { + const mem = new Map(); + const urls = new Set(); + const run = semaphore(3); + const cacheApi = typeof caches !== 'undefined' && globalThis.isSecureContext; + + function keyFor(situation, W, H, dpr) { + const bucket = + situation.archetype === 'eye' + ? Math.floor(Date.now() / 30_000) + : Math.floor((situation.updatedAt || 0) / 300_000); + return `${VERSION}/${encodeURIComponent(situation.id)}/${W}x${H}@${dpr}/${bucket}`; + } + + async function render(situation, W, H, dpr) { + const scene = SCENES.get(situation.id) || { band: situation.band }; + const type = scene.type && SCENE_DRAW[scene.type] ? scene.type : 'plain'; + const spanKm = + scene.spanKm || + (situation.camera?.height ? situation.camera.height / 1500 : 120); + const view = makeView( + Number.isFinite(scene.lon) ? scene.lon : situation.lon, + Number.isFinite(scene.lat) ? scene.lat : situation.lat, + spanKm, + W, + H, + ); + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const g = canvas.getContext('2d'); + const sc = { band: situation.band, ...scene, lon: view.lon, lat: view.lat }; + try { + await SCENE_DRAW[type](g, view, sc, dpr, feeds); + } catch (error) { + if (type === 'eye' || type === 'plain') throw error; + // An overlay failing must not cost the card its picture. + console.warn(`[globe:thumbnail] ${situation.id}`, error); + g.clearRect(0, 0, W, H); + await SCENE_DRAW.plain(g, view, sc, dpr, feeds); + } + return new Promise((resolve, reject) => + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('encode failed'))), + 'image/jpeg', + 0.86, + ), + ); + } + + return { + thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) { + if (!situation?.id) + return Promise.reject(new TypeError('thumbnail needs a situation')); + const d = Math.max(1, Math.min(3, dpr)); + const W = Math.round(width * d); + const H = Math.round(height * d); + const key = keyFor(situation, W, H, d); + let p = mem.get(key); + if (p) return p; + p = run(async () => { + const reqUrl = `${location.origin}/__gev-thumb/${key}`; + if (cacheApi) { + try { + const c = await caches.open(CACHE_NAME); + const hit = await c.match(reqUrl); + if (hit) return URL.createObjectURL(await hit.blob()); + } catch { + /* no Cache API (private mode) */ + } + } + const blob = await render(situation, W, H, d); + if (cacheApi) + caches + .open(CACHE_NAME) + .then((c) => + c.put( + reqUrl, + new Response(blob, { + headers: { 'Content-Type': 'image/jpeg' }, + }), + ), + ) + .catch(() => {}); + const url = URL.createObjectURL(blob); + urls.add(url); + return url; + }); + p.catch(() => mem.delete(key)); + mem.set(key, p); + while (mem.size > 120) mem.delete(mem.keys().next().value); + return p; + }, + destroy() { + for (const u of urls) URL.revokeObjectURL(u); + urls.clear(); + mem.clear(); + }, + }; +} diff --git a/mobile/globe/contact.css b/mobile/globe/contact.css new file mode 100644 index 000000000..cbf40bd92 --- /dev/null +++ b/mobile/globe/contact.css @@ -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; +} diff --git a/mobile/globe/contact.html b/mobile/globe/contact.html new file mode 100644 index 000000000..4bce3d096 --- /dev/null +++ b/mobile/globe/contact.html @@ -0,0 +1,16 @@ + + + + + + + Globe cards · contact sheet + + + +
+
building situations…
+
+ + + diff --git a/mobile/globe/contact.js b/mobile/globe/contact.js new file mode 100644 index 000000000..7ca4535eb --- /dev/null +++ b/mobile/globe/contact.js @@ -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 = ``; + 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'; diff --git a/mobile/globe/dev.js b/mobile/globe/dev.js index 201ac5977..db89d4686 100644 --- a/mobile/globe/dev.js +++ b/mobile/globe/dev.js @@ -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; diff --git a/mobile/globe/feeds.js b/mobile/globe/feeds.js index 1c03c4a41..9968a057f 100644 --- a/mobile/globe/feeds.js +++ b/mobile/globe/feeds.js @@ -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; diff --git a/mobile/globe/feedsWeather.js b/mobile/globe/feedsWeather.js new file mode 100644 index 000000000..8951c31e9 --- /dev/null +++ b/mobile/globe/feedsWeather.js @@ -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 }, + ); +} diff --git a/mobile/globe/gazetteer.js b/mobile/globe/gazetteer.js new file mode 100644 index 000000000..183ce8a0c --- /dev/null +++ b/mobile/globe/gazetteer.js @@ -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}`; +} diff --git a/mobile/globe/glyphs.js b/mobile/globe/glyphs.js index e4ab2fc4d..8a7219758 100644 --- a/mobile/globe/glyphs.js +++ b/mobile/globe/glyphs.js @@ -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. */ diff --git a/mobile/globe/index.js b/mobile/globe/index.js index d443ebe3b..fb0380c27 100644 --- a/mobile/globe/index.js +++ b/mobile/globe/index.js @@ -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, 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 (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 (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(); diff --git a/mobile/globe/layers/air.js b/mobile/globe/layers/air.js index b516d1126..05ff40c65 100644 --- a/mobile/globe/layers/air.js +++ b/mobile/globe/layers/air.js @@ -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, diff --git a/mobile/globe/layers/catalog.js b/mobile/globe/layers/catalog.js index 615e2bfb0..a16f9dc62 100644 --- a/mobile/globe/layers/catalog.js +++ b/mobile/globe/layers/catalog.js @@ -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(); diff --git a/mobile/globe/layers/hazards.js b/mobile/globe/layers/hazards.js index 782550496..fbe724d9b 100644 --- a/mobile/globe/layers/hazards.js +++ b/mobile/globe/layers/hazards.js @@ -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, diff --git a/mobile/globe/layers/weather.js b/mobile/globe/layers/weather.js new file mode 100644 index 000000000..d4477542f --- /dev/null +++ b/mobile/globe/layers/weather.js @@ -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 +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 }; diff --git a/mobile/globe/pins.js b/mobile/globe/pins.js new file mode 100644 index 000000000..9ed29142b --- /dev/null +++ b/mobile/globe/pins.js @@ -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); + }, + }; +} diff --git a/mobile/globe/situations.js b/mobile/globe/situations.js index 78fd403d9..99a8c8d03 100644 --- a/mobile/globe/situations.js +++ b/mobile/globe/situations.js @@ -1,20 +1,47 @@ /** - * Situation cards for the Now feed, built from the same live feeds the layers - * use. Every card carries real imagery of its place (keyless Esri export), - * a target-centred camera and the layers worth turning on. A feed that fails - * drops only its own cards: the bundled Bhote Koshi reconstruction and the - * evergreen places always resolve, so the feed is never empty. + * Situation cards for the Now feed (DESIGN.md §11.2): space → sky → ground, + * four archetypes (sensor, eye, track, number), every number from a live + * keyless feed with its source named. A genre whose feed fails, or that has + * nothing real to show right now, is omitted, never invented. + * + * Each card also registers a compositor scene (SCENES, keyed by card id): + * the data the thumbnail compositor draws over the imagery. Scenes stay out + * of the card objects so the shell can clone and serialise cards freely. */ import { twoline2satrec } from 'satellite.js'; import { findNextIssPass } from '../../src/data/issPass.js'; import { satPosition } from './layers/orbit.js'; -import { AIRPORTS, EVERGREEN, FORT_NOVOSEL } from './places.js'; -import { densestCell, haversineKm, relTime, thumbnailAround } from './geo.js'; +import { radiiFor, stormCategory } from './layers/hazards.js'; +import { loadWeatherFrame, strikeCells } from './layers/weather.js'; +import { AIRPORTS, FORT_NOVOSEL } from './places.js'; +import { + densestCell, + haversineKm, + relTime, + subsolarPoint, + thumbnailAround, +} from './geo.js'; +import { cctvFrameUrl } from './feeds.js'; +import { + GNSS_ANCHORS, + gnssIntegrity, + pointWind, + transitVehicles, +} from './feedsWeather.js'; +import { describePlace, US_METROS, compass } from './gazetteer.js'; const DAY = 86_400_000; -const FEED_BUDGET_MS = 9_000; -// OpenSky's global snapshot routinely takes 10-15 s through the proxy. -const FLIGHTS_BUDGET_MS = 16_000; +const HOUR = 3_600_000; +const FEED_BUDGET_MS = 12_000; +const FLIGHTS_BUDGET_MS = 26_000; +const SLOW_BUDGET_MS = 20_000; +/** No genre may hold the feed back longer than this (after its feeds). */ +const GENRE_BUDGET_MS = 25_000; + +/** Compositor scenes by situation id (see compositor.js). */ +export const SCENES = new Map(); +/** Verified camera frames fetched while building eye cards: id → {blob, at}. */ +export const EYE_FRAMES = new Map(); const withBudget = (promise, ms = FEED_BUDGET_MS) => Promise.race([ @@ -24,32 +51,44 @@ const withBudget = (promise, ms = FEED_BUDGET_MS) => ), ]); +const M_TO_FT = 3.28084; +const MPS_TO_KT = 1 / 0.514444; +const up = (s) => String(s).toUpperCase(); +const hhmmss = (ms) => new Date(ms).toISOString().slice(11, 19) + 'Z'; +const hhmm = (ms) => new Date(ms).toISOString().slice(11, 16) + 'Z'; +const fmtInt = (n) => Math.round(n).toLocaleString('en-US'); +const fl = (altM) => + `FL${String(Math.round((altM * M_TO_FT) / 100)).padStart(3, '0')}`; + +function fmtLatLon(lat, lon) { + return `${Math.abs(lat).toFixed(1)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(1)}°${lon >= 0 ? 'E' : 'W'}`; +} + function corridorKm(ev) { const c = ev?.reconstruction?.corridor; if (!c?.length) return 22; return Math.round(Math.abs(c.at(-1).chainageM - c[0].chainageM) / 100) / 10; } -function fmtLatLon(lat, lon) { - return `${Math.abs(lat).toFixed(1)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(1)}°${lon >= 0 ? 'E' : 'W'}`; -} - -function card(base) { +function card(base, scene) { const radiusKm = base.radiusKm ?? 60; const camHeight = base.camera?.height ?? radiusKm * 2500; - return { + const c = { id: base.id, kind: base.kind, family: base.family, section: base.section || 'now', + band: base.band, + archetype: base.archetype, + telemetry: base.telemetry, title: base.title, subtitle: base.subtitle || '', place: base.place || fmtLatLon(base.lat, base.lon), lon: base.lon, lat: base.lat, camera: { - lon: base.lon, - lat: base.lat, + lon: base.camera?.lon ?? base.lon, + lat: base.camera?.lat ?? base.lat, height: camHeight, heading: base.camera?.heading ?? 0, pitch: base.camera?.pitch ?? -50, @@ -60,12 +99,25 @@ function card(base) { layers: base.layers || [], ...(base.time ? { time: base.time } : {}), metrics: base.metrics || [], + ...(base.headline ? { headline: base.headline } : {}), + ...(base.media ? { media: base.media } : {}), + ...(base.series?.length > 1 + ? { series: base.series, seriesLabel: base.seriesLabel || '' } + : {}), thumbnail: base.thumbnail || thumbnailAround(base.lon, base.lat, radiusKm), live: base.live ?? true, updatedAt: base.updatedAt ?? Date.now(), source: base.source || '', severity: base.severity ?? 0, }; + SCENES.set(c.id, { + lon: base.lon, + lat: base.lat, + spanKm: base.spanKm ?? radiusKm * 2, + band: base.band, + ...scene, + }); + return c; } /** Region words for a lat/lon (coarse, for subtitles only). */ @@ -81,7 +133,6 @@ function regionOf(lat, lon) { return 'Open ocean'; } -/** Tropical cyclone basin for a position (for storms without an NHC basin). */ function basinOf(lat, lon) { if (lat >= 0) { if (lon >= 100 || lon < -180) return 'Western Pacific'; @@ -95,10 +146,205 @@ function basinOf(lat, lon) { return 'South Atlantic'; } +/** Sun elevation (deg) at a place, from the sub-solar point. */ +function sunElevation(lat, lon, ms) { + const s = subsolarPoint(ms); + const r = Math.PI / 180; + const cosZ = + Math.sin(lat * r) * Math.sin(s.lat * r) + + Math.cos(lat * r) * Math.cos(s.lat * r) * Math.cos((lon - s.lon) * r); + return 90 - Math.acos(Math.max(-1, Math.min(1, cosZ))) / r; +} + +/* ───────────────────────── Raster analysis ───────────────────────── */ + +/** Approximate dBZ for a pixel of nowCOAST's base-reflectivity palette. */ +export function dbzOf(r, g, b, a) { + if (a < 40) return null; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max < 40) return null; + if (min > 225) return 70; // white core + let h = 0; + const d = max - min || 1; + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h = (h * 60 + 360) % 360; + if (h >= 270 && h < 330) return 62; // magenta / purple + if (h >= 330 || h < 12) return max > 190 ? 52 : 57; // reds + if (h < 35) return 47; // orange + if (h < 70) return 41; // yellow + if (h < 160) return g > 170 ? 27 : 33; // greens (dark = heavier) + if (h < 200) return 12; // cyan + return 20; // blue +} + +/** Max dBZ and heavy-pixel count near a point in a whole-extent radar frame. */ +function radarNear(canvas, bounds, lat, lon, km) { + const g = canvas.getContext('2d', { willReadFrequently: true }); + const { width, height } = canvas; + const dLon = (bounds.east - bounds.west) / width; + const dLat = (bounds.north - bounds.south) / height; + const rLat = km / 111.32; + const rLon = km / (111.32 * Math.cos((lat * Math.PI) / 180)); + const x0 = Math.max(0, Math.floor((lon - rLon - bounds.west) / dLon)); + const x1 = Math.min(width - 1, Math.ceil((lon + rLon - bounds.west) / dLon)); + const y0 = Math.max(0, Math.floor((bounds.north - (lat + rLat)) / dLat)); + const y1 = Math.min( + height - 1, + Math.ceil((bounds.north - (lat - rLat)) / dLat), + ); + if (x1 <= x0 || y1 <= y0) return { max: null, heavy: 0, wet: 0 }; + const px = g.getImageData(x0, y0, x1 - x0 + 1, y1 - y0 + 1).data; + const zs = []; + let heavy = 0; + for (let i = 0; i < px.length; i += 4) { + const z = dbzOf(px[i], px[i + 1], px[i + 2], px[i + 3]); + if (z == null) continue; + zs.push(z); + if (z >= 40) heavy++; + } + if (!zs.length) return { max: null, heavy: 0, wet: 0 }; + zs.sort((a, b) => a - b); + // A robust peak: the 98th percentile, so one stray pixel is not "heavy". + const max = zs[Math.min(zs.length - 1, Math.floor(zs.length * 0.98))]; + return { max, heavy, wet: zs.length }; +} + +/** Lit-pixel area (km²) of a lightning frame inside a bbox. */ +function lightningArea(canvas, bounds, bbox) { + const cells = strikeCells(canvas, bounds, bbox); + const pxLon = (bounds.east - bounds.west) / canvas.width; + const pxLat = (bounds.north - bounds.south) / canvas.height; + let area = 0; + for (const c of cells) + area += + c.pixels * + pxLon * + 111.32 * + Math.cos((c.lat * Math.PI) / 180) * + pxLat * + 111.32; + return area; +} + +/** Group 0.5° strike cells into storm clusters (≈ 4° bins). */ +function lightningClusters(cells) { + const bins = new Map(); + for (const c of cells) { + const key = `${Math.floor(c.lat / 4)}:${Math.floor(c.lon / 4)}`; + let b = bins.get(key); + if (!b) bins.set(key, (b = { lat: 0, lon: 0, w: 0, cells: [], level: 0 })); + b.lat += c.lat * c.weight; + b.lon += c.lon * c.weight; + b.w += c.weight; + b.level = Math.max(b.level, c.level); + b.cells.push(c); + } + return [...bins.values()] + .map((b) => ({ ...b, lat: b.lat / b.w, lon: b.lon / b.w })) + .sort((a, b) => b.w - a.w); +} + +/* ───────────────────────── Eyes ───────────────────────── */ + +/** Famous busy places with public cameras in the catalog (matched by name). */ +const EYE_WISHLIST = [ + { re: /^Piccadilly Circus$/i, title: 'Piccadilly Circus', place: 'London' }, + { re: /^Trafalgar Square$/i, title: 'Trafalgar Square', place: 'London' }, + { re: /Tower Bridge App/i, title: 'Tower Bridge approach', place: 'London' }, + { + re: /^YONGE ST & DUNDAS ST$/i, + title: 'Yonge–Dundas Square', + place: 'Toronto', + }, + { + re: /towards Sydney Harbour Bridge/i, + title: 'Sydney Harbour Bridge', + place: 'Sydney', + }, + { + re: /Railway Square, George Street/i, + title: 'George Street, Railway Square', + place: 'Sydney', + }, + { + re: /I-5 at MP 165\.4/i, + title: 'I-5 through downtown Seattle', + place: 'Seattle', + }, + { + re: /Bay Bridge SAS Tower West/i, + title: 'Bay Bridge tower', + place: 'San Francisco', + }, + { + re: /Hollywood Blvd/i, + title: 'US-101 at Hollywood Blvd', + place: 'Los Angeles', + }, + { + re: /Helsinki Ruoholahti/i, + title: 'Ruoholahti, Helsinki', + place: 'Helsinki', + }, + { re: /^Viru väljak \(suund Mere/i, title: 'Viru Square', place: 'Tallinn' }, + { + re: /Diagonal - Maria Cristina/i, + title: 'Avinguda Diagonal', + place: 'Barcelona', + }, + { re: /^8 Ave & 1 St SE$/i, title: 'Downtown Calgary', place: 'Calgary' }, +]; + +function camCode(id) { + const m = /(\d{3,6})(?!.*\d)/.exec(String(id)); + return m ? m[1].slice(-5) : String(id).slice(-5).toUpperCase(); +} + +async function verifyFrame(cam) { + const url = cctvFrameUrl(cam); + const response = await fetch(url, { cache: 'no-store' }); + const kind = response.headers.get('x-cctv-source') || ''; + const type = response.headers.get('content-type') || ''; + if ( + !response.ok || + kind !== 'upstream-image' || + !/^image\/(jpeg|png|gif|webp)/.test(type) + ) + throw new Error(`no live frame (${kind || response.status})`); + const blob = await response.blob(); + if (blob.size < 4000) throw new Error('frame too small'); + EYE_FRAMES.set(cam.id, { blob, at: Date.now() }); + return url; +} + +/* ───────────────────────── Builder ───────────────────────── */ + export async function buildSituations(engine, { observer } = {}) { const { feeds } = engine; const now = Date.now(); const get = (feed, opts, ms) => withBudget(feed.get(opts), ms); + const settled = await Promise.allSettled([ + get(feeds.quakes), + get(feeds.cyclones), + get(feeds.fires), + get(feeds.military, { maxAgeMs: 60_000 }), + get(feeds.flights, { maxAgeMs: 60_000 }, FLIGHTS_BUDGET_MS), + get(feeds.stations), + get(feeds.launches), + get(feeds.flood), + get(feeds.radar), + get(feeds.lightning), + get(feeds.clouds), + get(feeds.tcm, undefined, SLOW_BUDGET_MS), + get(feeds.airQuality, undefined, SLOW_BUDGET_MS), + get(feeds.cctv, undefined, SLOW_BUDGET_MS), + get(feeds.transitCatalog), + get(feeds.starlink, undefined, SLOW_BUDGET_MS), + get(feeds.eonet), + ]); const [ quakes, cyclones, @@ -108,64 +354,52 @@ export async function buildSituations(engine, { observer } = {}) { stations, launches, flood, - ] = await Promise.allSettled([ - get(feeds.quakes), - get(feeds.cyclones), - get(feeds.fires), - get(feeds.military, { maxAgeMs: 60_000 }), - get(feeds.flights, { maxAgeMs: 60_000 }, FLIGHTS_BUDGET_MS), - get(feeds.stations), - get(feeds.launches), - get(feeds.flood), - ]); - const ok = (r) => (r.status === 'fulfilled' ? r.value : null); + radar, + lightning, + clouds, + tcm, + airQuality, + cctv, + transitCatalog, + starlink, + eonet, + ] = settled.map((r) => (r.status === 'fulfilled' ? r.value : null)); + // The jet stream grid is slow upstream (first load only; it is cached). + const jet = + feeds.jet.data || + (await withBudget(feeds.jet.get(), 10_000).catch(() => null)); + + const t0 = performance.now(); const out = []; - const tryAdd = (fn) => { - try { - const c = fn(); - if (Array.isArray(c)) out.push(...c); - else if (c) out.push(c); - } catch (error) { - console.warn('[globe:situations]', error); - } - }; + const omitted = []; + const tasks = []; + const timings = {}; + const add = (name, fn) => + tasks.push( + withBudget(Promise.resolve().then(fn), GENRE_BUDGET_MS) + .finally(() => { + timings[name] = Math.round(performance.now() - t0); + }) + .then( + (c) => { + if (Array.isArray(c)) out.push(...c.filter(Boolean)); + else if (c) out.push(c); + else omitted.push({ genre: name, reason: 'nothing to show now' }); + }, + (error) => { + omitted.push({ + genre: name, + reason: error?.message || String(error), + }); + console.warn(`[globe:situations] ${name}`, error); + }, + ), + ); - // Largest quake in the past 24 h. - tryAdd(() => { - const d = ok(quakes); - if (!d) return null; - const day = d.records.filter((q) => q.time && now - q.time < DAY); - if (!day.length) return null; - const q = day.reduce((a, b) => (b.mag > a.mag ? b : a)); - return card({ - id: `quake-${q.stableId}`, - kind: 'earthquake', - family: 'Hazards', - title: `M${q.mag.toFixed(1)} earthquake`, - subtitle: `Largest in 24 h · ${relTime(q.time, now)}`, - place: q.place || fmtLatLon(q.lat, q.lon), - lon: q.lon, - lat: q.lat, - radiusKm: 120, - camera: { height: 450_000, heading: 0, pitch: -65 }, - layers: ['earthquakes'], - time: { start: q.time, end: now }, - metrics: [ - { label: 'Magnitude', value: Number(q.mag.toFixed(1)), unit: 'M' }, - { label: 'Depth', value: Math.round(q.depthKm ?? 0), unit: 'km' }, - { label: 'M2.5+ in 24 h', value: day.length, unit: '' }, - ], - updatedAt: feeds.quakes.updatedAt, - source: 'USGS', - severity: q.mag, - }); - }); - - // Active tropical cyclones. - tryAdd(() => { - const d = ok(cyclones); - if (!d) return null; - return d.records.slice(0, 4).map((s) => { + /* ── Cyclones: GIBS-style IR + NHC cone, track, wind radii ── */ + add('cyclones', () => { + if (!cyclones) throw new Error('cyclone feeds unavailable'); + return cyclones.records.slice(0, 3).map((s) => { const label = s.label || { @@ -178,152 +412,863 @@ export async function buildSituations(engine, { observer } = {}) { 'Storm'; const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null; const lastTau = s.forecast?.at(-1)?.tauHours ?? 0; - return card({ - id: `cyclone-${s.id}`, - kind: 'cyclone', - family: 'Hazards', - title: `${label} ${s.name}`, - subtitle: [ - kmh ? `${kmh} km/h` : null, - s.pressureHpa ? `${s.pressureHpa} hPa` : null, - s.forecast?.length ? `${lastTau} h forecast track` : null, - ] - .filter(Boolean) - .join(' · '), - place: `${{ EP: 'Eastern Pacific', AL: 'Atlantic', CP: 'Central Pacific' }[s.basin] || basinOf(s.lat, s.lon)} basin`, - lon: s.lon, - lat: s.lat, - radiusKm: 700, - camera: { height: 2_600_000, heading: 0, pitch: -75 }, - layers: ['cyclones'], - time: { start: s.t, end: s.t + lastTau * 3_600_000 }, - metrics: [ - ...(kmh ? [{ label: 'Wind', value: kmh, unit: 'km/h' }] : []), - ...(s.pressureHpa - ? [{ label: 'Pressure', value: s.pressureHpa, unit: 'hPa' }] - : []), - ...(s.movement?.speedKt - ? [ - { - label: 'Moving', - value: Math.round(s.movement.speedKt * 1.852), - unit: 'km/h', - }, - ] - : []), - ], - updatedAt: feeds.cyclones.updatedAt, - source: s.source, - severity: 5 + (s.windKt || 0) / 40, - }); + const radii = radiiFor(s, tcm)?.radii || null; + const r34 = radii?.find((r) => r.kt === 34); + const cat = stormCategory(s.windKt); + return card( + { + id: `cyclone-${s.id}`, + kind: 'cyclone', + family: 'Hazards', + band: 'orbit', + archetype: 'sensor', + telemetry: [ + 'ORBIT', + 'LW IR', + up(`${s.classification || ''} ${s.name}`.trim()), + cat && cat !== 'TS' && cat !== 'TD' ? cat : null, + s.windKt ? `${s.windKt} KT` : null, + s.pressureHpa ? `${s.pressureHpa} HPA` : null, + ] + .filter(Boolean) + .join(' · '), + title: `${label} ${s.name}`, + subtitle: [ + kmh ? `${kmh} km/h winds` : null, + s.movement?.directionDegrees != null && s.movement?.speedKt + ? `moving ${compass(s.movement.directionDegrees)} at ${Math.round(s.movement.speedKt * 1.852)} km/h` + : null, + describePlace(s.lat, s.lon), + ] + .filter(Boolean) + .join(' · '), + place: `${{ EP: 'Eastern Pacific', AL: 'Atlantic', CP: 'Central Pacific' }[s.basin] || basinOf(s.lat, s.lon)}`, + lon: s.lon, + lat: s.lat, + radiusKm: 700, + spanKm: Math.max( + 1600, + (r34 ? Math.max(...r34.quadrantsKm) * 4 : 0) + 900, + ), + camera: { height: 2_600_000, heading: 0, pitch: -75 }, + layers: ['cyclones', 'clouds', 'lightning', 'wind'], + time: { start: s.t, end: s.t + lastTau * HOUR }, + metrics: [ + ...(kmh ? [{ label: 'Wind', value: kmh, unit: 'km/h' }] : []), + ...(s.pressureHpa + ? [{ label: 'Pressure', value: s.pressureHpa, unit: 'hPa' }] + : []), + ...(r34 + ? [ + { + label: 'Gale radius', + value: Math.round(Math.max(...r34.quadrantsKm)), + unit: 'km', + }, + ] + : []), + ...(lastTau + ? [{ label: 'Forecast', value: lastTau, unit: 'h' }] + : []), + ], + headline: kmh + ? { value: kmh, unit: 'km/h', label: cat || 'wind' } + : undefined, + updatedAt: feeds.cyclones.updatedAt, + source: `${s.source}${radii ? ' · NHC wind radii' : ''} · IR: NOAA nowCOAST`, + severity: 6 + (s.windKt || 0) / 40, + }, + { + type: 'cyclone', + storm: { + lon: s.lon, + lat: s.lat, + windKt: s.windKt, + cone: s.cone, + forecast: s.forecast || [], + pastTrack: s.pastTrack || null, + track: s.track || null, + radii, + }, + irTime: clouds?.latest || null, + irBounds: clouds?.bounds || null, + }, + ); }); }); - // Fire cluster: Europe first if it has a real cluster, else the densest. - tryAdd(() => { - const d = ok(fires); - if (!d?.records.length) return null; - const hotspots = d.mode === 'hotspots'; - const recent = hotspots - ? d.records.filter((f) => !f.t || now - f.t < DAY) - : d.records; - const cell = hotspots ? 1 : 4; - const europe = densestCell( - recent.filter((f) => regionOf(f.lat, f.lon) === 'Europe'), - cell, + /* ── Lightning storm cells (number + sensor) ── */ + add('lightning', async () => { + if (!lightning) throw new Error('lightning manifest unavailable'); + const canvas = await loadWeatherFrame('lightning', lightning.latest, 1024); + const cells = strikeCells(canvas, lightning.bounds); + const clusters = lightningClusters(cells); + if (!clusters.length) return null; + const picks = [clusters[0]]; + const second = clusters.find( + (c) => haversineKm(c.lat, c.lon, clusters[0].lat, clusters[0].lon) > 1500, ); + if (second && second.w > clusters[0].w * 0.15) picks.push(second); + const cards = []; + for (const [i, c] of picks.entries()) { + const lats = c.cells.map((x) => x.lat); + const lons = c.cells.map((x) => x.lon); + const bbox = { + west: Math.min(...lons) - 1, + east: Math.max(...lons) + 1, + south: Math.min(...lats) - 1, + north: Math.max(...lats) + 1, + }; + const series = []; + if (i === 0) { + for (const t of lightning.times) { + try { + const f = await loadWeatherFrame('lightning', t, 1024, { + keep: t === lightning.latest, + }); + series.push({ + t: Date.parse(t), + v: Math.round(lightningArea(f, lightning.bounds, bbox)), + }); + } catch { + /* a missing frame leaves a gap */ + } + } + } + const area = lightningArea(canvas, lightning.bounds, bbox); + const where = describePlace(c.lat, c.lon); + const spanKm = Math.min( + 1400, + Math.max( + 350, + haversineKm(bbox.south, bbox.west, bbox.north, bbox.east) * 0.75, + ), + ); + const inRadar = + radar && + c.lon > radar.bounds.west && + c.lon < radar.bounds.east && + c.lat > radar.bounds.south && + c.lat < radar.bounds.north; + const peakNote = + c.level >= 3 ? 'intense' : c.level === 2 ? 'strong' : 'active'; + cards.push( + card( + { + id: `lightning-${Math.round(c.lat)}-${Math.round(c.lon)}`, + kind: 'lightning', + family: 'Weather', + band: 'sky', + archetype: i === 0 ? 'number' : 'sensor', + telemetry: `SKY · LIGHTNING · 15 MIN · ${fmtInt(area)} KM² · ${hhmm(Date.parse(lightning.latest))}`, + title: + i === 0 + ? `Thunderstorms · ${where}` + : `Lightning cell · ${where}`, + subtitle: `${c.cells.length} lightning-active 0.5° cells, ${peakNote} strike density in the last 15 min`, + place: where, + lon: c.lon, + lat: c.lat, + radiusKm: spanKm / 2, + spanKm, + camera: { height: spanKm * 2200, heading: 0, pitch: -70 }, + layers: ['lightning', 'clouds', ...(inRadar ? ['radar'] : [])], + time: { + start: Date.parse(lightning.times[0]), + end: Date.parse(lightning.latest), + }, + metrics: [ + { label: 'Active area', value: Math.round(area), unit: 'km²' }, + { label: 'Active cells', value: c.cells.length, unit: '' }, + ], + headline: { + value: fmtInt(area), + unit: 'km²', + label: 'lightning-active area, 15 min', + }, + series, + seriesLabel: 'lightning-active area (km²) per 15 min', + updatedAt: Date.parse(lightning.latest), + source: 'NOAA nowCOAST lightning density (Vaisala NLDN/GLD360)', + severity: 4 + Math.min(2, c.w / 200) - i, + }, + { + type: 'lightning', + times: lightning.times, + bounds: lightning.bounds, + radar: inRadar + ? { time: radar.latest, bounds: radar.bounds } + : null, + irTime: clouds?.latest || null, + irBounds: clouds?.bounds || null, + }, + ), + ); + } + return cards; + }); + + /* ── Rain over a US city (radar) ── */ + add('radar', async () => { + if (!radar) throw new Error('radar manifest unavailable'); + const canvas = await loadWeatherFrame('radar', radar.latest, 2048); + let best = null; + for (const [name, lon, lat] of US_METROS) { + const r = radarNear(canvas, radar.bounds, lat, lon, 45); + if (r.max == null) continue; + const score = r.max * 10 + r.heavy * 0.5 + r.wet * 0.02; + if (!best || score > best.score) best = { name, lon, lat, ...r, score }; + } + if (!best || best.max < 20) return null; + const series = []; + for (const t of radar.times) { + try { + const f = await loadWeatherFrame('radar', t, 2048, { + keep: t === radar.latest, + }); + const r = radarNear(f, radar.bounds, best.lat, best.lon, 45); + series.push({ t: Date.parse(t), v: r.max ?? 0 }); + } catch { + /* gap */ + } + } + const heavy = best.max >= 40; + return card( + { + id: `radar-${best.name.replace(/\W+/g, '-').toLowerCase()}`, + kind: 'radar', + family: 'Weather', + band: 'sky', + archetype: 'sensor', + telemetry: `SKY · MRMS · ${best.max} DBZ · ${up(best.name)} · ${hhmm(Date.parse(radar.latest))}`, + title: `${heavy ? 'Heavy rain' : best.max >= 30 ? 'Rain' : 'Showers'} over ${best.name}`, + subtitle: `Radar returns to ~${best.max} dBZ within 45 km${best.heavy ? ` · ${best.heavy} heavy-echo pixels` : ''}`, + place: best.name, + lon: best.lon, + lat: best.lat, + radiusKm: 60, + spanKm: 230, + camera: { height: 160_000, heading: 0, pitch: -55 }, + layers: ['radar', 'lightning'], + time: { + start: Date.parse(radar.times[0]), + end: Date.parse(radar.latest), + }, + metrics: [ + { label: 'Peak reflectivity', value: best.max, unit: 'dBZ' }, + { label: 'Frames', value: radar.times.length, unit: '' }, + ], + headline: { value: best.max, unit: 'dBZ', label: 'peak reflectivity' }, + series, + seriesLabel: 'peak reflectivity (dBZ) within 45 km', + updatedAt: Date.parse(radar.latest), + source: + 'NOAA MRMS base reflectivity via nowCOAST (dBZ read from colour scale)', + severity: 2 + (best.max - 20) / 10, + }, + { + type: 'radar', + time: radar.latest, + times: radar.times, + bounds: radar.bounds, + }, + ); + }); + + /* ── Jet stream core ── */ + add('jet', () => { + if (!jet?.field) throw new Error('jet-stream grid not loaded yet'); + const { field, speed } = jet; + let best = null; + for (let i = 0; i < speed.length; i++) { + const lat = field.la1 - Math.floor(i / field.nx) * field.dy; + if (Math.abs(lat) < 20 || Math.abs(lat) > 70) continue; + if (!best || speed[i] > best.s) + best = { + i, + s: speed[i], + lat, + lon: field.lo1 + (i % field.nx) * field.dx, + }; + } + if (!best) return null; + const kt = Math.round(best.s * MPS_TO_KT); + const kmh = Math.round(best.s * 3.6); + const where = describePlace(best.lat, best.lon); + return card( + { + id: `jet-${best.lat}-${best.lon}`, + kind: 'jet', + family: 'Weather', + band: 'sky', + archetype: 'sensor', + telemetry: `SKY · 250 HPA · FL340 · ${kt} KT · ${fmtLatLon(best.lat, best.lon).replace(/°/g, '')}`, + title: `Jet stream core · ${kmh} km/h`, + subtitle: `Fastest 250 hPa wind on a 12° global grid · ${where}`, + place: where, + lon: best.lon, + lat: best.lat, + radiusKm: 2500, + spanKm: 7000, + camera: { height: 7_500_000, heading: 0, pitch: -90 }, + layers: ['jet', 'clouds'], + metrics: [ + { label: 'Core speed', value: kmh, unit: 'km/h' }, + { label: 'Level', value: 250, unit: 'hPa' }, + ], + headline: { value: kt, unit: 'kt', label: 'jet core at FL340' }, + updatedAt: jet.time, + source: jet.source, + severity: 2 + Math.max(0, kt - 120) / 40, + }, + { type: 'jet', windLevel: 'jet' }, + ); + }); + + /* ── Quakes: largest in 24 h (sensor) + global count (number) ── */ + add('quakes', () => { + if (!quakes) throw new Error('USGS unavailable'); + const day = quakes.records.filter((q) => q.time && now - q.time < DAY); + if (!day.length) return null; + const q = day.reduce((a, b) => (b.mag > a.mag ? b : a)); + const nearby = quakes.records + .filter((r) => r !== q && haversineKm(q.lat, q.lon, r.lat, r.lon) < 400) + .map((r) => ({ lon: r.lon, lat: r.lat, mag: r.mag, time: r.time })); + const series = []; + for (let h = 23; h >= 0; h--) { + const t0 = now - (h + 1) * HOUR; + series.push({ + t: t0 + HOUR, + v: quakes.records.filter((r) => r.time >= t0 && r.time < t0 + HOUR) + .length, + }); + } + const biggest = card( + { + id: `quake-${q.stableId}`, + kind: 'earthquake', + family: 'Hazards', + band: 'ground', + archetype: 'sensor', + telemetry: `GROUND · M${q.mag.toFixed(1)} · ${Math.round(q.depthKm ?? 0)} KM DEEP · ${hhmmss(q.time)}`, + title: `M${q.mag.toFixed(1)} earthquake`, + subtitle: `Largest in 24 h · ${relTime(q.time, now)} · ${nearby.length} other M2.5+ within 400 km this week`, + place: q.place || describePlace(q.lat, q.lon), + lon: q.lon, + lat: q.lat, + radiusKm: 120, + spanKm: 260, + camera: { height: 450_000, heading: 0, pitch: -65 }, + layers: ['earthquakes'], + time: { start: q.time, end: now }, + metrics: [ + { label: 'Magnitude', value: Number(q.mag.toFixed(1)), unit: 'M' }, + { label: 'Depth', value: Math.round(q.depthKm ?? 0), unit: 'km' }, + { label: 'M2.5+ in 24 h', value: day.length, unit: '' }, + ], + headline: { value: q.mag.toFixed(1), unit: 'M', label: 'magnitude' }, + updatedAt: feeds.quakes.updatedAt, + source: 'USGS', + severity: q.mag, + }, + { + type: 'quake', + quake: { lon: q.lon, lat: q.lat, mag: q.mag, depthKm: q.depthKm }, + nearby, + }, + ); + const busiest = densestCell(day, 30) || q; + const count = card( + { + id: 'quakes-24h', + kind: 'earthquake', + family: 'Hazards', + band: 'ground', + archetype: 'number', + telemetry: `GROUND · USGS · ${day.length} EVENTS M2.5+ · 24 H`, + title: `${day.length} earthquakes M2.5+ in 24 h`, + subtitle: `Worldwide · largest M${q.mag.toFixed(1)} ${q.place ? `· ${q.place}` : ''}`, + place: 'Worldwide', + lon: busiest.lon, + lat: Math.max(-50, Math.min(55, busiest.lat)), + radiusKm: 4000, + spanKm: 11000, + camera: { height: 18_000_000, heading: 0, pitch: -90 }, + layers: ['earthquakes'], + time: { start: now - DAY, end: now }, + metrics: [{ label: 'M2.5+ in 24 h', value: day.length, unit: '' }], + headline: { value: day.length, unit: '', label: 'quakes M2.5+ · 24 h' }, + series, + seriesLabel: 'M2.5+ quakes per hour', + updatedAt: feeds.quakes.updatedAt, + source: 'USGS', + severity: 2, + }, + { + type: 'quake-world', + quakes: quakes.records.slice(0, 900).map((r) => ({ + lon: r.lon, + lat: r.lat, + mag: r.mag, + time: r.time, + })), + }, + ); + return [biggest, count]; + }); + + /* ── Fires with smoke direction from wind ── */ + add('fires', async () => { + if (!fires?.records.length) throw new Error('fire feeds unavailable'); + const hotspots = fires.mode === 'hotspots'; + const recent = hotspots + ? fires.records.filter((f) => !f.t || now - f.t < DAY) + : fires.records; + const cell = hotspots ? 1 : 4; + // Prescribed burns are real but not news: prefer wildfires. + const wild = recent.filter( + (f) => !/prescribed|\bRX\b/i.test(f.title || ''), + ); + const pool = wild.length ? wild : recent; + const largest = hotspots + ? null + : pool.reduce( + (a, b) => ((b.acres ?? 0) > (a.acres ?? 0) ? b : a), + pool[0], + ); const best = - europe && europe.count >= (hotspots ? 20 : 2) - ? europe - : densestCell(recent, cell); + largest?.acres > 0 + ? { + count: pool.filter( + (f) => haversineKm(f.lat, f.lon, largest.lat, largest.lon) < 300, + ).length, + lat: largest.lat, + lon: largest.lon, + items: [ + largest, + ...pool.filter( + (f) => + f !== largest && + haversineKm(f.lat, f.lon, largest.lat, largest.lon) < 60, + ), + ], + } + : densestCell(pool, cell); if (!best) return null; - const region = regionOf(best.lat, best.lon); const sample = best.items.find( (f) => f.title && f.title !== 'Fire hotspot', ); - return card({ - id: `fires-${best.lat.toFixed(1)}-${best.lon.toFixed(1)}`, - kind: 'fires', - family: 'Hazards', - title: `Fire cluster · ${region}`, - subtitle: hotspots - ? `${best.count} hotspots in 24 h` - : sample - ? sample.title - : `${best.count} active wildfire events`, - place: sample?.title?.split(',').slice(1).join(',').trim() || region, - lon: best.lon, - lat: best.lat, - radiusKm: hotspots ? 90 : 220, - camera: { height: hotspots ? 260_000 : 700_000, heading: 0, pitch: -60 }, - layers: ['fires'], - metrics: [ + const focus = sample || best.items[0]; + let w = null; + try { + w = await withBudget( + pointWind.get(`${focus.lat.toFixed(1)}:${focus.lon.toFixed(1)}`, { + lon: focus.lon, + lat: focus.lat, + }), + 8000, + ); + } catch { + /* no plume without wind */ + } + const to = w ? (w.fromDeg + 180) % 360 : null; + const where = describePlace(focus.lat, focus.lon); + return card( + { + id: `fires-${focus.lat.toFixed(1)}-${focus.lon.toFixed(1)}`, + kind: 'fires', + family: 'Hazards', + band: 'ground', + archetype: 'sensor', + telemetry: [ + 'GROUND', + hotspots ? 'VIIRS/MODIS' : 'EONET', + w ? `WIND ${Math.round(w.speedKmh)} KM/H → ${compass(to)}` : null, + fmtLatLon(focus.lat, focus.lon).replace(/°/g, ''), + ] + .filter(Boolean) + .join(' · '), + title: sample + ? sample.title.split(',')[0] + : `Fire cluster · ${regionOf(best.lat, best.lon)}`, + subtitle: [ + hotspots + ? `${best.count} hotspots in 24 h` + : `${best.count} open wildfire event${best.count > 1 ? 's' : ''} nearby`, + w + ? `smoke drifting ${compass(to)} on ${Math.round(w.speedKmh)} km/h winds` + : null, + ] + .filter(Boolean) + .join(' · '), + place: where, + lon: focus.lon, + lat: focus.lat, + radiusKm: 30, + spanKm: 70, + camera: { height: 80_000, heading: to ?? 0, pitch: -45 }, + layers: ['fires', 'wind', 'air-quality'], + metrics: [ + { + label: hotspots ? 'Hotspots' : 'Events', + value: best.count, + unit: '', + }, + ...(w + ? [{ label: 'Wind', value: Math.round(w.speedKmh), unit: 'km/h' }] + : []), + { label: 'Worldwide', value: recent.length, unit: '' }, + ], + updatedAt: feeds.fires.updatedAt, + source: `${fires.source}${w ? ' · wind: Open-Meteo' : ''}`, + severity: 3 + Math.min(2, best.count / 50), + }, + { + type: 'fire', + fires: best.items.map((f) => ({ + lon: f.lon, + lat: f.lat, + frp: f.frp ?? null, + acres: f.acres ?? null, + })), + wind: w ? { toDeg: to, speedKmh: w.speedKmh } : null, + }, + ); + }); + + /* ── Aircraft: squawks, busiest airport, Fort Novosel, military ── */ + let flightRecords = flights?.records || []; + // OpenSky down: the proxy answers per anchor (250 nm of adsb.lol), so a + // few busy regions are sampled to keep squawks and airports meaningful. + if (!flights || flights.regional) { + const anchors = [ + [51.47, -0.45], + [40.64, -73.78], + [33.64, -84.43], + [50.04, 8.57], + [35.55, 139.78], + ]; + const extra = await Promise.allSettled( + anchors.map(([lat, lon]) => + withBudget( + feeds.regionalFlights.get(`${lat}:${lon}`, { lat, lon }), + FLIGHTS_BUDGET_MS, + ), + ), + ); + const byId = new Map(flightRecords.map((r) => [r.id, r])); + for (const r of extra) + if (r.status === 'fulfilled') + for (const f of r.value.records) byId.set(f.id, f); + flightRecords = [...byId.values()]; + } + const aircraftScene = (lon, lat, km, list) => + list + .filter((r) => haversineKm(lat, lon, r.lat, r.lon) <= km) + .slice(0, 400) + .map((r) => ({ + lon: r.lon, + lat: r.lat, + heading: r.heading, + speed: r.speed, + altM: r.altM, + onGround: r.onGround, + helicopter: r.helicopter, + emergency: r.emergency, + military: r.military, + })); + + add('squawk', () => { + if (!flightRecords.length) throw new Error('flight feed unavailable'); + const em = flightRecords.filter((r) => r.emergency && !r.onGround); + if (!em.length) return null; + return em.slice(0, 2).map((r) => { + const meaning = { + 7700: 'General emergency', + 7600: 'Radio failure', + 7500: 'Unlawful interference', + }[r.squawk]; + const kt = Number.isFinite(r.speed) + ? Math.round(r.speed * MPS_TO_KT) + : null; + const where = describePlace(r.lat, r.lon); + const descending = r.vRate != null && r.vRate < -3; + return card( { - label: hotspots ? 'Hotspots' : 'Events', - value: best.count, - unit: '', + id: `squawk-${r.id}`, + kind: 'squawk', + family: 'Sky', + band: 'sky', + archetype: 'track', + telemetry: [ + 'SKY', + fl(r.altM), + kt ? `${kt} KT` : null, + `SQ ${r.squawk}`, + up(r.callsign), + ] + .filter(Boolean) + .join(' · '), + title: `${r.callsign} squawking ${r.squawk}`, + subtitle: [ + meaning, + `${fmtInt(Math.round((r.altM * M_TO_FT) / 100) * 100)} ft`, + descending + ? `descending ${fmtInt(Math.abs(r.vRate) * 196.85)} ft/min` + : null, + where, + ] + .filter(Boolean) + .join(' · '), + place: where, + lon: r.lon, + lat: r.lat, + radiusKm: 60, + spanKm: 220, + camera: { height: 120_000, heading: r.heading, pitch: -45 }, + layers: ['squawk', 'flights'], + metrics: [ + { label: 'Squawk', value: r.squawk, unit: '' }, + { + label: 'Altitude', + value: Math.round(r.altM * M_TO_FT), + unit: 'ft', + }, + ...(kt ? [{ label: 'Ground speed', value: kt, unit: 'kt' }] : []), + ], + headline: { value: r.squawk, unit: '', label: meaning }, + updatedAt: feeds.flights.updatedAt, + source: flights?.source || 'adsb.lol (regional snapshots)', + severity: 7, }, - { label: 'Worldwide', value: recent.length, unit: '' }, - ], - updatedAt: feeds.fires.updatedAt, - source: d.source, - severity: 3 + Math.min(2, best.count / 50), + { + type: 'aircraft', + focus: { + lon: r.lon, + lat: r.lat, + heading: r.heading, + speed: r.speed, + altM: r.altM, + emergency: true, + }, + aircraft: aircraftScene(r.lon, r.lat, 160, flightRecords), + }, + ); }); }); - // Military air activity hotspot. - tryAdd(() => { - const d = ok(military); - if (!d?.records.length) return null; - const airborne = d.records.filter((r) => !r.onGround); + add('airport', () => { + if (!flightRecords.length) throw new Error('flight feed unavailable'); + let best = null; + for (const ap of AIRPORTS) { + let n = 0; + for (const r of flightRecords) { + if (Math.abs(r.lat - ap.lat) > 0.6 || Math.abs(r.lon - ap.lon) > 0.9) + continue; + if (r.altM > 4000) continue; + if (haversineKm(ap.lat, ap.lon, r.lat, r.lon) <= 50) n++; + } + if (!best || n > best.n) best = { ...ap, n }; + } + if (!best?.n) return null; + return card( + { + id: `airport-${best.code}`, + kind: 'airport', + family: 'Sky', + band: 'sky', + archetype: 'sensor', + telemetry: `SKY · ${best.code} · ${best.n} AIRCRAFT < FL130 · 50 KM`, + title: `Busiest airspace · ${best.code}`, + subtitle: `${best.n} aircraft below 4 km within 50 km of ${best.name}`, + place: best.name, + lon: best.lon, + lat: best.lat, + radiusKm: 40, + spanKm: 110, + camera: { height: 60_000, heading: 20, pitch: -45 }, + layers: ['flights', 'ga', 'helicopters'], + metrics: [ + { label: 'Low aircraft', value: best.n, unit: '' }, + { + label: 'Airborne worldwide', + value: flightRecords.filter((r) => !r.onGround).length, + unit: '', + }, + ], + headline: { value: best.n, unit: '', label: 'aircraft below FL130' }, + updatedAt: feeds.flights.updatedAt, + source: flights?.source || 'adsb.lol (regional snapshots)', + severity: 1.5, + }, + { + type: 'aircraft', + aircraft: aircraftScene(best.lon, best.lat, 90, flightRecords), + airport: { lon: best.lon, lat: best.lat }, + }, + ); + }); + + add('fort-novosel', async () => { + let records = flightRecords; + let source = flights?.source || 'OpenSky Network'; + const near = (r) => + haversineKm(FORT_NOVOSEL.lat, FORT_NOVOSEL.lon, r.lat, r.lon) <= + FORT_NOVOSEL.radiusKm; + if (!records.some(near)) { + const reg = await withBudget( + feeds.regionalFlights.get('novosel', { + lat: FORT_NOVOSEL.lat, + lon: FORT_NOVOSEL.lon, + }), + FLIGHTS_BUDGET_MS, + ).catch(() => null); + if (reg) { + records = reg.records; + source = 'adsb.lol / OpenSky'; + } + } + const local = records.filter(near); + const mil = (military?.records || []).filter( + (r) => r.helicopter && near(r), + ); + const helis = [...local.filter((r) => r.helicopter), ...mil]; + const lowSlow = local.filter( + (r) => + !r.onGround && r.altM < 900 && (r.speed ?? 99) < 60 && !r.helicopter, + ); + if (!helis.length && !lowSlow.length) return null; + const lead = helis[0] || lowSlow[0]; + return card( + { + id: 'fort-novosel', + kind: 'helicopters', + family: 'Sky', + section: 'always', + band: 'sky', + archetype: 'track', + telemetry: + `SKY · KOZR · ${helis.length} ROTOR · ${lowSlow.length} LOW+SLOW · ${lead ? fl(lead.altM) : ''}`.replace( + / · $/, + '', + ), + title: 'Helicopter school circuits', + subtitle: `${helis.length} rotorcraft · ${lowSlow.length} low & slow contacts over Fort Novosel`, + place: 'Fort Novosel, Alabama', + lon: FORT_NOVOSEL.lon, + lat: FORT_NOVOSEL.lat, + radiusKm: 35, + spanKm: 80, + camera: { height: 45_000, heading: 340, pitch: -45 }, + layers: ['helicopters', 'military', 'ga'], + metrics: [ + { label: 'Rotorcraft', value: helis.length, unit: '' }, + { label: 'Low & slow', value: lowSlow.length, unit: '' }, + { label: 'All aircraft', value: local.length, unit: '' }, + ], + headline: { + value: helis.length + lowSlow.length, + unit: '', + label: 'training contacts', + }, + updatedAt: now, + source, + severity: 1.2, + }, + { + type: 'aircraft', + aircraft: aircraftScene(FORT_NOVOSEL.lon, FORT_NOVOSEL.lat, 70, [ + ...local, + ...mil, + ]), + }, + ); + }); + + add('military', () => { + if (!military?.records.length) throw new Error('military feed unavailable'); + const airborne = military.records.filter((r) => !r.onGround); const best = densestCell(airborne, 6); if (!best || best.count < 2) return null; const types = [ ...new Set(best.items.map((r) => r.type).filter(Boolean)), ].slice(0, 3); - return card({ - id: `military-${Math.round(best.lat)}-${Math.round(best.lon)}`, - kind: 'military', - family: 'Sky', - title: `Military air activity · ${regionOf(best.lat, best.lon)}`, - subtitle: `${best.count} military aircraft${types.length ? ` · ${types.join(', ')}` : ''}`, - lon: best.lon, - lat: best.lat, - radiusKm: 260, - camera: { height: 900_000, heading: 0, pitch: -70 }, - layers: ['military'], - metrics: [ - { label: 'In area', value: best.count, unit: '' }, - { label: 'Tracked worldwide', value: airborne.length, unit: '' }, - ], - updatedAt: feeds.military.updatedAt, - source: 'adsb.lol', - severity: 2.5, - }); + const where = describePlace(best.lat, best.lon); + return card( + { + id: `military-${Math.round(best.lat)}-${Math.round(best.lon)}`, + kind: 'military', + family: 'Sky', + band: 'sky', + archetype: 'sensor', + telemetry: + `SKY · MIL · ${best.count} AIRCRAFT · ${types.join(' ').toUpperCase()}`.trim(), + title: `Military air activity · ${regionOf(best.lat, best.lon)}`, + subtitle: `${best.count} military aircraft${types.length ? ` · ${types.join(', ')}` : ''} · ${where}`, + place: where, + lon: best.lon, + lat: best.lat, + radiusKm: 260, + spanKm: Math.max( + 160, + Math.min( + 900, + 2.6 * + Math.max( + ...best.items.map((r) => + haversineKm(best.lat, best.lon, r.lat, r.lon), + ), + ), + ), + ), + camera: { height: 900_000, heading: 0, pitch: -70 }, + layers: ['military'], + metrics: [ + { label: 'In area', value: best.count, unit: '' }, + { label: 'Tracked worldwide', value: airborne.length, unit: '' }, + ], + updatedAt: feeds.military.updatedAt, + source: 'adsb.lol', + severity: 2.5, + }, + { + type: 'aircraft', + military: true, + aircraft: best.items.map((r) => ({ + lon: r.lon, + lat: r.lat, + heading: r.heading, + speed: r.speed, + altM: r.altM, + military: true, + helicopter: r.helicopter, + })), + }, + ); }); - // ISS now (+ next pass when we know where the viewer is). - tryAdd(() => { - const d = ok(stations); - const iss = d?.entries.find((e) => e.line1.slice(2, 7).trim() === '25544'); - if (!iss) return null; + /* ── ISS and the newest Starlink train ── */ + add('iss', () => { + const iss = stations?.entries.find( + (e) => e.line1.slice(2, 7).trim() === '25544', + ); + if (!iss) throw new Error('CelesTrak stations unavailable'); const satrec = twoline2satrec(iss.line1, iss.line2); const p = satPosition(satrec, now); if (!p) return null; + const altKm = Math.round(p.altM / 1000); + const kms = (p.speedMps / 1000).toFixed(2); + let subtitle = `Over ${describePlace(p.lat, p.lon)} · ${altKm} km up`; + let passTag = null; const metrics = [ - { label: 'Altitude', value: Math.round(p.altM / 1000), unit: 'km' }, + { label: 'Altitude', value: altKm, unit: 'km' }, { label: 'Speed', value: Math.round((p.speedMps * 3.6) / 100) * 100, unit: 'km/h', }, ]; - let subtitle = `Over ${regionOf(p.lat, p.lon)} · ${fmtLatLon(p.lat, p.lon)}`; - if ( - observer && - Number.isFinite(observer.lat) && - Number.isFinite(observer.lon) - ) { + if (observer && Number.isFinite(observer.lat)) { const pass = findNextIssPass({ satrec, latDeg: observer.lat, @@ -333,6 +1278,7 @@ export async function buildSituations(engine, { observer } = {}) { }); if (pass) { subtitle = `Next pass over you ${relTime(pass.riseMs, now)} · max ${Math.round(pass.maxElevDeg)}°`; + passTag = `PASS ${hhmm(pass.riseMs).replace('Z', '')}`; metrics.push({ label: 'Next pass', value: new Date(pass.riseMs).toISOString(), @@ -340,227 +1286,714 @@ export async function buildSituations(engine, { observer } = {}) { }); } } - return card({ - id: 'iss-now', - kind: 'iss', - family: 'Orbit', - title: 'ISS now', - subtitle, - place: regionOf(p.lat, p.lon), - lon: p.lon, - lat: p.lat, - radiusKm: 900, - camera: { height: 5_500_000, heading: 0, pitch: -90 }, - layers: ['iss', 'satellites'], - metrics, - updatedAt: now, - source: 'CelesTrak TLE · SGP4', - severity: 2, - }); + return card( + { + id: 'iss-now', + kind: 'iss', + family: 'Orbit', + band: 'orbit', + archetype: 'track', + telemetry: ['ORBIT', 'ISS', `${altKm} KM`, `${kms} KM/S`, passTag] + .filter(Boolean) + .join(' · '), + title: 'ISS now', + subtitle, + place: regionOf(p.lat, p.lon), + lon: p.lon, + lat: p.lat, + radiusKm: 2500, + spanKm: 9000, + camera: { height: 5_500_000, heading: 0, pitch: -90 }, + layers: ['iss', 'satellites'], + metrics, + headline: { value: kms, unit: 'km/s', label: `${altKm} km up` }, + updatedAt: now, + source: 'CelesTrak TLE · SGP4', + severity: 2, + }, + { + type: 'orbit', + sats: [{ line1: iss.line1, line2: iss.line2, name: 'ISS', main: true }], + at: now, + observer: observer || null, + }, + ); }); - // Busiest airport airspace now, and the Fort Novosel helicopter school. - tryAdd(() => { - const d = ok(flights) || { records: [], source: 'OpenSky Network' }; - const flightsLive = d.records.length > 0; - const cards = []; - let best = null; - for (const ap of AIRPORTS) { - let n = 0; - for (const r of d.records) { - if (Math.abs(r.lat - ap.lat) > 0.6 || Math.abs(r.lon - ap.lon) > 0.9) - continue; - if (r.altM > 4000) continue; - if (haversineKm(ap.lat, ap.lon, r.lat, r.lon) <= 50) n++; - } - if (!best || n > best.n) best = { ...ap, n }; + add('starlink', () => { + const entries = starlink?.entries; + if (!entries?.length) throw new Error('Starlink TLEs unavailable'); + // International designator YYNNN: the newest launch still flying in a train. + const byLaunch = new Map(); + for (const e of entries) { + const id = e.line1.slice(9, 14).trim(); + if (!/^\d{5}$/.test(id)) continue; + if (!byLaunch.has(id)) byLaunch.set(id, []); + byLaunch.get(id).push(e); } - if (flightsLive && best && best.n > 0) - cards.push( - card({ - id: `airport-${best.code}`, - kind: 'airport', - family: 'Sky', - title: `Busiest airspace · ${best.code}`, - subtitle: `${best.n} aircraft below 4 km within 50 km of ${best.name}`, - place: best.name, - lon: best.lon, - lat: best.lat, - radiusKm: 40, - camera: { height: 60_000, heading: 20, pitch: -45 }, - layers: ['flights', 'ga', 'helicopters'], + const launchKey = (k) => + (Number(k.slice(0, 2)) < 57 ? 2000 : 1900) * 1000 + + Number(k.slice(0, 2)) * 1000 + + Number(k.slice(2)); + const launchesSorted = [...byLaunch.keys()].sort( + (a, b) => launchKey(b) - launchKey(a), + ); + for (const key of launchesSorted.slice(0, 4)) { + const group = byLaunch.get(key); + if (group.length < 8) continue; + const pts = []; + for (const e of group) { + try { + const p = satPosition(twoline2satrec(e.line1, e.line2), now); + if (p) pts.push({ ...p, e }); + } catch { + /* skip */ + } + } + if (pts.length < 8) continue; + // A train: the largest group still within 1,500 km of one satellite. + let core = []; + let c = null; + for (const p of pts) { + const n = pts.filter( + (q) => haversineKm(p.lat, p.lon, q.lat, q.lon) < 1500, + ); + if (n.length > core.length) { + core = n; + c = p; + } + } + if (core.length < 8) continue; + pts.length = 0; + pts.push(...core); + const spread = Math.max( + ...pts.map((p) => haversineKm(c.lat, c.lon, p.lat, p.lon)), + ); + const year = 2000 + Number(key.slice(0, 2)); + const altKm = Math.round( + pts.reduce((a, p) => a + p.altM, 0) / pts.length / 1000, + ); + return card( + { + id: `starlink-${key}`, + kind: 'starlink', + family: 'Orbit', + band: 'orbit', + archetype: 'track', + telemetry: `ORBIT · STARLINK ${year}-${key.slice(2)} · ${pts.length} SATS · ${altKm} KM`, + title: `Starlink train · ${pts.length} satellites`, + subtitle: `Launch ${year}-${key.slice(2)} still flying in formation · over ${describePlace(c.lat, c.lon)}`, + place: regionOf(c.lat, c.lon), + lon: c.lon, + lat: c.lat, + radiusKm: 2000, + spanKm: Math.max(5000, spread * 2.6), + camera: { height: 4_500_000, heading: 0, pitch: -90 }, + layers: ['starlink'], metrics: [ - { label: 'Low aircraft', value: best.n, unit: '' }, + { label: 'Satellites', value: pts.length, unit: '' }, + { label: 'Mean altitude', value: altKm, unit: 'km' }, { - label: 'Airborne worldwide', - value: d.records.filter((r) => !r.onGround).length, - unit: '', + label: 'Train length', + value: Math.round(spread * 2), + unit: 'km', }, ], - updatedAt: feeds.flights.updatedAt, - source: d.source, - severity: 1.5, - }), + headline: { + value: pts.length, + unit: '', + label: 'satellites in the train', + }, + updatedAt: now, + source: 'CelesTrak TLE · SGP4', + severity: 1.8, + }, + { + type: 'orbit', + sats: pts.slice(0, 60).map((p, i) => ({ + line1: p.e.line1, + line2: p.e.line2, + main: i === Math.floor(pts.length / 2), + })), + at: now, + train: true, + }, ); - const near = d.records.filter( - (r) => - haversineKm(FORT_NOVOSEL.lat, FORT_NOVOSEL.lon, r.lat, r.lon) <= - FORT_NOVOSEL.radiusKm, - ); - const mil = ok(military)?.records || []; - const helis = [ - ...near.filter((r) => r.helicopter), - ...mil.filter( - (r) => - r.helicopter && - haversineKm(FORT_NOVOSEL.lat, FORT_NOVOSEL.lon, r.lat, r.lon) <= - FORT_NOVOSEL.radiusKm, - ), - ]; - const lowSlow = near.filter( - (r) => - !r.onGround && r.altM < 900 && (r.speed ?? 99) < 60 && !r.helicopter, - ); - cards.push( - card({ - id: 'fort-novosel', - kind: 'helicopters', - family: 'Sky', - section: 'always', - title: 'Helicopter school circuits', - subtitle: !flightsLive - ? 'US Army Aviation Center of Excellence · live traffic unavailable' - : helis.length || lowSlow.length - ? `${helis.length} rotorcraft · ${lowSlow.length} low & slow contacts over Fort Novosel` - : 'Quiet right now · US Army Aviation Center of Excellence', - place: 'Fort Novosel, Alabama', - lon: FORT_NOVOSEL.lon, - lat: FORT_NOVOSEL.lat, - radiusKm: 35, - camera: { height: 45_000, heading: 340, pitch: -45 }, - layers: ['helicopters', 'military', 'ga'], + } + // No train in formation right now: the constellation over the night side. + const sun = subsolarPoint(now); + const center = + observer && Number.isFinite(observer.lat) + ? { lat: observer.lat, lon: observer.lon } + : { lat: 40, lon: ((sun.lon + 180 - 25 + 540) % 360) - 180 }; + const sats = []; + let overhead = 0; + for (const e of entries) { + try { + const p = satPosition(twoline2satrec(e.line1, e.line2), now); + if (!p) continue; + sats.push(e); + if (haversineKm(center.lat, center.lon, p.lat, p.lon) < 2000) + overhead++; + } catch { + /* skip */ + } + } + if (!sats.length) return null; + const where = observer ? 'you' : describePlace(center.lat, center.lon); + return card( + { + id: 'starlink-shell', + kind: 'starlink', + family: 'Orbit', + band: 'orbit', + archetype: 'number', + telemetry: `ORBIT · STARLINK · ${fmtInt(sats.length)} SATS · ${overhead} WITHIN 2,000 KM`, + title: `${fmtInt(sats.length)} Starlink satellites in orbit`, + subtitle: `${overhead} within 2,000 km of ${where} right now`, + place: observer ? 'Over you' : describePlace(center.lat, center.lon), + lon: center.lon, + lat: center.lat, + radiusKm: 3000, + spanKm: 8000, + camera: { height: 9_000_000, heading: 0, pitch: -90 }, + layers: ['starlink'], metrics: [ - { label: 'Rotorcraft', value: helis.length, unit: '' }, - { label: 'Low & slow', value: lowSlow.length, unit: '' }, - { label: 'All aircraft', value: near.length, unit: '' }, + { label: 'In orbit', value: sats.length, unit: '' }, + { label: 'Within 2,000 km', value: overhead, unit: '' }, ], - updatedAt: feeds.flights.updatedAt || now, - source: d.source, - live: flightsLive, - severity: helis.length ? 1.2 : 0.2, - }), + headline: { + value: fmtInt(sats.length), + unit: '', + label: 'Starlink satellites', + }, + updatedAt: now, + source: 'CelesTrak TLE · SGP4', + severity: 1.4, + }, + { + type: 'constellation', + sats: sats.map((e) => ({ line1: e.line1, line2: e.line2 })), + at: now, + }, ); - return cards; }); - // Next launch. - tryAdd(() => { - const d = ok(launches); - const next = d?.records.find((r) => r.net > now - 3600_000); - if (!next) return null; - return card({ - id: `launch-${next.id}`, - kind: 'launch', - family: 'Orbit', - title: next.name, - subtitle: `${next.provider || 'Launch'} · T−${relTime(next.net, now).replace('in ', '')}`, - place: next.location || next.pad, - lon: next.lon, - lat: next.lat, - radiusKm: 25, - camera: { height: 30_000, heading: 0, pitch: -40 }, - layers: ['launches', 'iss'], - time: { start: next.net - 3600_000, end: next.net + 3600_000 }, - metrics: [ - { label: 'NET', value: new Date(next.net).toISOString(), unit: 'utc' }, - ...(next.orbit - ? [{ label: 'Orbit', value: next.orbit, unit: '' }] - : []), - ...(next.status - ? [{ label: 'Status', value: next.status, unit: '' }] - : []), - ], - updatedAt: feeds.launches.updatedAt, - source: d.source, - severity: next.net - now < 2 * DAY ? 3 : 1, - live: d.upcomingOk, + /* ── Next launch ── */ + add('launch', () => { + const next = launches?.records.find((r) => r.net > now - HOUR); + if (!next) throw new Error('launch feeds unavailable'); + const tMinus = next.net - now; + const hh = Math.floor(Math.abs(tMinus) / HOUR); + const mm = Math.floor((Math.abs(tMinus) % HOUR) / 60_000); + const vehicle = next.name.split('|')[0].trim(); + return card( + { + id: `launch-${next.id}`, + kind: 'launch', + family: 'Orbit', + band: 'orbit', + archetype: 'sensor', + telemetry: [ + 'ORBIT', + `T${tMinus >= 0 ? '−' : '+'}${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`, + up(vehicle).slice(0, 18), + next.orbit ? up(next.orbit) : null, + ] + .filter(Boolean) + .join(' · '), + title: next.name, + subtitle: `${next.provider || 'Launch'} · ${tMinus >= 0 ? `T−${relTime(next.net, now).replace('in ', '')}` : `launched ${relTime(next.net, now)}`} · ${next.pad}`, + place: next.location || next.pad, + lon: next.lon, + lat: next.lat, + radiusKm: 120, + spanKm: 420, + camera: { height: 30_000, heading: 0, pitch: -40 }, + layers: ['launches', 'iss'], + time: { start: next.net - HOUR, end: next.net + HOUR }, + metrics: [ + { + label: 'NET', + value: new Date(next.net).toISOString(), + unit: 'utc', + }, + ...(next.orbit + ? [{ label: 'Orbit', value: next.orbit, unit: '' }] + : []), + ...(next.status + ? [{ label: 'Status', value: next.status, unit: '' }] + : []), + ], + updatedAt: feeds.launches.updatedAt, + source: `${launches.source} · ascent azimuth indicative`, + severity: tMinus < 2 * DAY ? 3 : 1, + live: launches.upcomingOk, + }, + { + type: 'launch', + pad: { lon: next.lon, lat: next.lat }, + orbit: next.orbit || '', + }, + ); + }); + + /* ── GNSS interference ── */ + add('gnss', async () => { + const snaps = await Promise.allSettled( + GNSS_ANCHORS.map((a) => + withBudget(gnssIntegrity.get(`${a.lat}:${a.lon}`, a), 12_000).then( + (s) => ({ a, s }), + ), + ), + ); + const ok = snaps + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + if (!ok.length) + throw new Error( + snaps[0]?.reason?.message || 'GNSS integrity unavailable', + ); + let best = null; + for (const { a, s } of ok) { + if (s.rows.length < 8) continue; + const frac = s.degraded / s.rows.length; + const score = s.degraded * frac; + if (!best || score > best.score) best = { a, s, frac, score }; + } + if (!best || best.s.degraded < 4 || best.frac < 0.15) return null; + const pct = Math.round(best.frac * 100); + const bad = best.s.cells.filter((c) => c.level !== 'low'); + const hot = bad.length + ? bad.reduce((x, y) => (y.percent > x.percent ? y : x)) + : null; + const c = hot || best.a; + return card( + { + id: `gnss-${best.a.id}`, + kind: 'gnss', + family: 'Infra', + band: 'sky', + archetype: 'number', + telemetry: `SKY · GNSS · ${pct}% DEGRADED · ${best.s.rows.length} AIRCRAFT · 250 NM`, + title: `GNSS interference · ${best.a.name}`, + subtitle: `${best.s.degraded} of ${best.s.rows.length} aircraft report degraded GPS integrity (low NIC/NACp)`, + place: best.a.name, + lon: c.lon, + lat: c.lat, + radiusKm: 350, + spanKm: 900, + camera: { height: 1_100_000, heading: 0, pitch: -80 }, + layers: ['gnss', 'flights'], + metrics: [ + { label: 'Degraded aircraft', value: best.s.degraded, unit: '' }, + { label: 'Sampled aircraft', value: best.s.rows.length, unit: '' }, + { label: 'Anomalous cells', value: bad.length, unit: '' }, + ], + headline: { + value: pct, + unit: '%', + label: 'aircraft with degraded GNSS', + }, + updatedAt: best.s.fetchedAt, + source: + 'adsb.lol NIC/NACp · gpsjam.org bands (a proxy for jamming, not proof)', + severity: 2 + Math.min(3, pct / 15), + }, + { type: 'gnss', rows: best.s.rows, cells: best.s.cells, anchor: best.a }, + ); + }); + + /* ── Transit at (or nearest to) rush hour ── */ + add('transit', async () => { + if (!transitCatalog) throw new Error('transit catalog unavailable'); + const TZ = { + mbta: 'America/New_York', + 'capmetro-austin': 'America/Chicago', + 'metrotransit-msp': 'America/Chicago', + 'hsl-helsinki': 'Europe/Helsinki', + 'ovapi-nl': 'Europe/Amsterdam', + 'entur-norway': 'Europe/Oslo', + 'translink-seq': 'Australia/Brisbane', + 'ttc-toronto': 'America/Toronto', + }; + const localHour = (tz) => { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + hour: 'numeric', + minute: 'numeric', + hour12: false, + }).formatToParts(new Date(now)); + const h = Number(parts.find((p) => p.type === 'hour')?.value); + const m = Number(parts.find((p) => p.type === 'minute')?.value); + return (h % 24) + m / 60; + }; + const ranked = transitCatalog.feeds + .filter((f) => TZ[f.id]) + .map((f) => { + const h = localHour(TZ[f.id]); + return { f, h, gap: Math.min(Math.abs(h - 8.25), Math.abs(h - 17.5)) }; + }) + .sort((a, b) => a.gap - b.gap); + for (const { f, h, gap } of ranked.slice(0, 3)) { + const snap = await withBudget(transitVehicles.get(f.id), 14_000).catch( + () => null, + ); + if (!snap || snap.vehicles.length < 10) continue; + const moving = snap.vehicles.filter((v) => (v.speedMps ?? 0) > 1).length; + const rush = gap <= 1.25; + const clock = `${String(Math.floor(h)).padStart(2, '0')}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`; + const city = f.region.split(',')[0]; + const center = densestCell(snap.vehicles, 0.15) || { + lat: f.center.lat, + lon: f.center.lon, + }; + return card( + { + id: `transit-${f.id}`, + kind: 'transit', + family: 'Ground', + band: 'ground', + archetype: 'sensor', + telemetry: `GROUND · GTFS-RT · ${snap.vehicles.length} VEHICLES · ${clock} LOCAL`, + title: `${rush ? 'Rush hour' : 'Transit now'} · ${city}`, + subtitle: `${snap.vehicles.length} ${f.name} vehicles reporting · ${moving} moving · ${clock} local time`, + place: f.region, + lon: center.lon, + lat: center.lat, + radiusKm: 10, + spanKm: 24, + camera: { height: 18_000, heading: 0, pitch: -50 }, + layers: ['transit'], + metrics: [ + { label: 'Vehicles', value: snap.vehicles.length, unit: '' }, + { label: 'Moving', value: moving, unit: '' }, + ], + headline: { + value: snap.vehicles.length, + unit: '', + label: 'vehicles live', + }, + updatedAt: snap.fetchedAt, + source: `${f.name} GTFS-realtime · ${f.attribution}`, + severity: rush ? 1.6 : 0.8, + }, + { type: 'transit', vehicles: snap.vehicles }, + ); + } + return null; + }); + + /* ── Eyes: live public cameras at famous busy places ── */ + add('eyes', async () => { + if (!cctv?.records.length) throw new Error('camera catalog unavailable'); + const found = []; + for (const wish of EYE_WISHLIST) { + const cam = cctv.records.find((c) => wish.re.test(c.name)); + if (cam) found.push({ wish, cam }); + } + found.sort( + (a, b) => + (sunElevation(b.cam.lat, b.cam.lon, now) > -6) - + (sunElevation(a.cam.lat, a.cam.lon, now) > -6), + ); + const checked = await Promise.allSettled( + found.slice(0, 11).map(({ wish, cam }) => + withBudget(verifyFrame(cam), 12_000).then((url) => ({ + wish, + cam, + url, + })), + ), + ); + const live = checked + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + .slice(0, 9); + return live.map(({ wish, cam, url }, i) => { + const daylight = sunElevation(cam.lat, cam.lon, now) > -6; + return card( + { + id: `eye-${cam.id}`, + kind: 'camera', + family: 'Eyes', + band: 'ground', + archetype: 'eye', + telemetry: `GROUND · CAM ${camCode(cam.id)} · ${up(wish.title).slice(0, 22)} · ${hhmmss(EYE_FRAMES.get(cam.id)?.at || now)}`, + title: wish.title, + subtitle: `${wish.place} · ${cam.provider}${daylight ? '' : ' · night'} · looking ${compass(cam.heading)}`, + place: wish.place, + lon: cam.lon, + lat: cam.lat, + radiusKm: 1, + spanKm: 1.5, + camera: { height: 600, heading: cam.heading, pitch: -25 }, + layers: ['cctv'], + media: { + kind: 'image', + url, + refreshMs: 30_000, + cameraId: cam.id, + headingDeg: cam.heading, + provider: cam.provider, + city: cam.city, + }, + metrics: [ + { label: 'Looking', value: compass(cam.heading), unit: '' }, + ], + updatedAt: EYE_FRAMES.get(cam.id)?.at || now, + source: `${cam.provider} public camera`, + severity: 1.4 - i * 0.05 + (daylight ? 0.3 : 0), + }, + { type: 'eye', cameraId: cam.id }, + ); }); }); - // Bundled reconstruction: always present. - tryAdd(() => { - const ev = ok(flood)?.event; + /* ── Worst air quality (modelled) ── */ + add('air-quality', () => { + if (!airQuality?.records.length) + throw new Error('air-quality feed unavailable'); + const worst = airQuality.records.reduce((a, b) => (b.aqi > a.aqi ? b : a)); + if (worst.aqi < 80) return null; + const label = + worst.aqi > 200 + ? 'Very unhealthy air' + : worst.aqi > 150 + ? 'Unhealthy air' + : 'Unhealthy air for sensitive groups'; + return card( + { + id: `aq-${worst.name.toLowerCase().replace(/\W+/g, '-')}`, + kind: 'air-quality', + family: 'Hazards', + band: 'ground', + archetype: 'number', + telemetry: `GROUND · PM2.5 ${Math.round(worst.pm25)} µG/M³ · AQI ${Math.round(worst.aqi)} · CAMS`, + title: `${label} · ${worst.name}`, + subtitle: `PM2.5 AQI ${Math.round(worst.aqi)} · worst of ${airQuality.records.length} major cities (model, not a monitor)`, + place: worst.name, + lon: worst.lon, + lat: worst.lat, + radiusKm: 30, + spanKm: 700, + camera: { height: 60_000, heading: 0, pitch: -45 }, + layers: ['air-quality', 'fires', 'wind'], + metrics: [ + { label: 'PM2.5 AQI', value: Math.round(worst.aqi), unit: '' }, + { label: 'PM2.5', value: Math.round(worst.pm25), unit: 'µg/m³' }, + ], + headline: { + value: Math.round(worst.aqi), + unit: 'AQI', + label: 'US air quality index', + }, + series: worst.series, + seriesLabel: 'PM2.5 (µg/m³), last 24 h', + updatedAt: worst.t, + source: airQuality.source, + severity: 1.5 + (worst.aqi - 100) / 60, + }, + { type: 'aq', aqi: worst.aqi }, + ); + }); + + /* ── Volcano (most recently updated open EONET event) ── */ + add('volcano', () => { + const v = eonet?.volcanoes?.slice().sort((a, b) => b.t - a.t)[0]; + if (!v) return null; + return card( + { + id: `volcano-${v.id}`, + kind: 'volcano', + family: 'Hazards', + band: 'ground', + archetype: 'sensor', + telemetry: `GROUND · VOLCANO · ${fmtLatLon(v.lat, v.lon).replace(/°/g, '')} · EONET`, + title: v.title.replace(/,.*$/, ''), + subtitle: `Open eruption event since ${new Date(v.firstT).toISOString().slice(0, 10)} · ${describePlace(v.lat, v.lon)}`, + place: + v.title.split(',').slice(1).join(',').trim() || + regionOf(v.lat, v.lon), + lon: v.lon, + lat: v.lat, + radiusKm: 12, + spanKm: 26, + camera: { height: 25_000, heading: 0, pitch: -40 }, + layers: ['volcanoes'], + updatedAt: v.t, + source: 'NASA EONET', + live: false, + severity: 1.3, + }, + { type: 'volcano' }, + ); + }); + + /* ── Ships at chokepoints (only when AIS is configured) ── */ + add('chokepoint', async () => { + let vessels; + try { + vessels = await withBudget(feeds.vessels.get(), 6000); + } catch (error) { + throw new Error( + error?.keyRequired + ? 'AIS not configured on this server (AISSTREAM_API_KEY)' + : 'AIS unavailable', + ); + } + const POINTS = [ + ['Strait of Hormuz', 56.45, 26.55], + ['Singapore Strait', 103.85, 1.22], + ['Suez Canal', 32.35, 30.6], + ['Panama Canal', -79.82, 9.2], + ['Strait of Gibraltar', -5.6, 35.95], + ['Bab-el-Mandeb', 43.4, 12.6], + ]; + let best = null; + for (const [name, lon, lat] of POINTS) { + const near = vessels.records.filter( + (v) => haversineKm(lat, lon, v.lat, v.lon) < 60, + ); + if (!best || near.length > best.near.length) + best = { name, lon, lat, near }; + } + if (!best?.near.length) return null; + const slow = best.near.filter((v) => (v.speed ?? 0) < 0.5).length; + return card( + { + id: `choke-${best.name.toLowerCase().replace(/\W+/g, '-')}`, + kind: 'vessels', + family: 'Sea', + band: 'ground', + archetype: 'sensor', + telemetry: `GROUND · AIS · ${best.near.length} VESSELS · ${slow} AT ANCHOR`, + title: best.name, + subtitle: `${best.near.length} vessels within 60 km · ${slow} stopped or at anchor`, + place: best.name, + lon: best.lon, + lat: best.lat, + radiusKm: 40, + spanKm: 120, + camera: { height: 120_000, heading: 0, pitch: -55 }, + layers: ['vessels', 'tankers'], + metrics: [ + { label: 'Vessels', value: best.near.length, unit: '' }, + { label: 'Stopped', value: slow, unit: '' }, + ], + updatedAt: feeds.vessels.updatedAt, + source: vessels.source, + severity: 1.5, + }, + { + type: 'vessels', + vessels: best.near.map((v) => ({ + lon: v.lon, + lat: v.lat, + heading: v.heading, + speed: v.speed, + })), + }, + ); + }); + + /* ── Bundled reconstruction: always present ── */ + add('bhote-koshi', () => { + const ev = flood?.event; const vp = ev?.viewpoints?.corridor; const lon = vp?.lon ?? 85.3548; const lat = vp?.lat ?? 28.207; const observed = ev?.observedDate || '2026-08-26'; - return card({ - id: 'bhote-koshi-2026', - kind: 'reconstruction', - family: 'Hazards', - section: 'reconstruction', - title: ev?.title || 'Bhote Koshi Outburst Flood', - subtitle: `Reconstruction · ${ev?.evidenceSpine?.length ?? 0} evidence beats · 2021 vs 2026 imagery`, - place: 'Rasuwa, Nepal', - lon, - lat, - radiusKm: 9, - camera: { - height: vp - ? Math.round( - (vp.rangeM || 8600) * - Math.sin(Math.abs(vp.pitchDeg || 39) * (Math.PI / 180)), - ) - : 5400, - heading: vp?.headingDeg ?? 344, - pitch: vp?.pitchDeg ?? -39, - targetHeight: vp?.targetElevationM ?? 1536, - }, - layers: ['floods'], - time: { - start: Date.parse(`${observed}T00:00:00Z`), - end: Date.parse(`${observed}T12:00:00Z`), - at: Date.parse(`${observed}T05:30:00Z`), - }, - metrics: [ - { label: 'Corridor', value: corridorKm(ev), unit: 'km' }, - { - label: 'Witness anchors', - value: ev?.witnessAnchors?.length ?? 0, - unit: '', + return card( + { + id: 'bhote-koshi-2026', + kind: 'reconstruction', + family: 'Hazards', + section: 'reconstruction', + band: 'ground', + archetype: 'sensor', + telemetry: `GROUND · RECON · ${corridorKm(ev)} KM CORRIDOR · ${observed.replace(/-/g, '')}`, + title: ev?.title || 'Bhote Koshi Outburst Flood', + subtitle: `Reconstruction · ${ev?.evidenceSpine?.length ?? 0} evidence beats · 2021 vs 2026 imagery`, + place: 'Rasuwa, Nepal', + lon, + lat, + radiusKm: 9, + spanKm: 16, + camera: { + height: vp + ? Math.round( + (vp.rangeM || 8600) * + Math.sin(Math.abs(vp.pitchDeg || 39) * (Math.PI / 180)), + ) + : 5400, + heading: vp?.headingDeg ?? 344, + pitch: vp?.pitchDeg ?? -39, + targetHeight: vp?.targetElevationM ?? 1536, }, - { - label: 'Field reports', - value: ev?.fieldReports?.length ?? 0, - unit: '', + layers: ['floods'], + time: { + start: Date.parse(`${observed}T00:00:00Z`), + end: Date.parse(`${observed}T12:00:00Z`), + at: Date.parse(`${observed}T05:30:00Z`), }, - ], - updatedAt: Date.parse(observed), - live: false, - source: 'GeoPera · Vantor Open Data · bundled event pack', - severity: 4, - }); + metrics: [ + { label: 'Corridor', value: corridorKm(ev), unit: 'km' }, + { + label: 'Witness anchors', + value: ev?.witnessAnchors?.length ?? 0, + unit: '', + }, + { + label: 'Field reports', + value: ev?.fieldReports?.length ?? 0, + unit: '', + }, + ], + updatedAt: Date.parse(observed), + live: false, + source: 'GeoPera · Vantor Open Data · bundled event pack', + severity: 4, + }, + { + type: 'flood', + path: flood?.path || [], + corridor: flood?.corridor || null, + anchors: (ev?.witnessAnchors || []).map((a) => ({ + lon: a.lon, + lat: a.lat, + })), + }, + ); }); - // Evergreen places. - for (const e of EVERGREEN) { - tryAdd(() => - card({ - ...e, - id: `always-${e.id}`, - kind: 'place', - section: 'always', - live: false, - updatedAt: now, - source: 'Bundled place', - severity: 0, - }), - ); + await Promise.allSettled(tasks); + buildSituations.lastOmitted = omitted; + buildSituations.lastTimings = timings; + return interleave(out); +} + +/** + * Severity first, then avoid putting two cards of the same band or archetype + * side by side when another card is close behind (DESIGN.md §11.2). + */ +function interleave(cards) { + const rest = cards.slice().sort((a, b) => b.severity - a.severity); + const out = []; + while (rest.length) { + const prev = out.at(-1); + let k = 0; + if (prev) { + const look = Math.min(rest.length, 6); + const differs = (c) => + c.band !== prev.band && c.archetype !== prev.archetype; + const half = (c) => + c.band !== prev.band || c.archetype !== prev.archetype; + k = rest.slice(0, look).findIndex(differs); + if (k < 0) k = rest.slice(0, look).findIndex(half); + if (k < 0) k = 0; + } + out.push(rest.splice(k, 1)[0]); } - - const live = out - .filter((c) => c.section === 'now') - .sort((a, b) => b.severity - a.severity); - const rest = out.filter((c) => c.section !== 'now'); - return [...live, ...rest]; + return out; } diff --git a/mobile/vision/index.html b/mobile/vision/index.html index f80ac0f64..02c3534f0 100644 --- a/mobile/vision/index.html +++ b/mobile/vision/index.html @@ -578,6 +578,369 @@ input, textarea { font: inherit; color: inherit; } *, *::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; } +
+ +
@@ -718,7 +1083,7 @@ input, textarea { font: inherit; color: inherit; } - + @@ -727,14 +1092,17 @@ input, textarea { font: inherit; color: inherit; }
+
Concept · illustrative data
- -
God’s Eye
-
The window to the world.
+
ORBIT36,000–400 KM
SKY15 KM
GROUND0 M
INSIDEA WITNESS
+
ALT 35,786 KM
+
+
WITNESS · SEALED · ±4 M
+
GOD’S EYE
From orbit to the witness.
@@ -1265,6 +1633,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 }); @@ -2475,6 +2861,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 @@ -2491,6 +2880,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'); }; @@ -2681,14 +3071,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]); @@ -2976,6 +3366,755 @@ SC.draw = function (sc, view, { sun, t, eye, opts }) { // ground height (m) at lat/lon for scenes with terrain SC.groundAt = function (lat, lon) { const sc = SC.byId.koshi; if (!sc.height) return 0; const [x, y] = enuOf(sc, lat, lon); return sc.height(x, y); }; +// ---- 36-paint.js +/* 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]; + // ---- 40-camera.js /* Camera: orbit (target lat/lon, range, heading, tilt, lens shift) with touch/pinch/wheel and inertia; fly-to along eased great-circle arcs with a log-space altitude hump (van Wijk-style); @@ -3207,7 +4346,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(); })(); @@ -3293,11 +4433,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: () => [`T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}`, '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: () => [`T+${fmt.dur((Date.now() - LAUNCH_T0) / 1000)}`, '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, @@ -3381,6 +4521,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])); @@ -3390,12 +4545,272 @@ 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; }; SIT.pin = (s) => { if (s.id === 'starlink' || s.id === 'squawk' || s.id === 'iss') { const c = SIT.cameraFor(s); return null; } return { id: s.id, lat: s.lat, lon: s.lon, title: s.title, col: [...hex2rgb(fam(s.fam)), 1], seed: hash01(s.id.length) }; }; +// ---- 54-rooms.js +/* 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 }; +}; + +// ---- 55-feed.js +/* 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; }; + // ---- 60-shell.js /* Shell: tabs, Liquid-Glass tab bar with sliding lens, tab-bar accessory (followed situation), minimize-on-scroll, layout (phone / two-pane / hinge), overlay labels, toasts. */ @@ -3466,11 +4881,17 @@ APP.updateChrome = function () { /* ---------- accessory ---------- */ APP.renderAccessory = function () { - const s = GE.SIT.byId[APP.following]; if (!s) return; - const html = `
${esc(GE.CYCLONE.name)}
${esc(s.accessory ? s.accessory() : '')}
${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}`; - $('#acc').innerHTML = html; - $('#accMini').innerHTML = `
${esc(GE.CYCLONE.name)}
${esc(s.accessory ? s.accessory() : '')}
`; + // 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 = `
${esc(ttl)}
${esc(sub)}
`; + 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 = `
${esc(ttl)}
${esc(sub)}
`; 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)]); @@ -3491,6 +4912,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: `${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: `${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: `${esc(GE.world.sel.title)}`, 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); @@ -3525,7 +4948,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) @@ -3605,74 +5028,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 ? `4DThread` : s.live ? `Live` : `${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}`; - const right = s.thread ? `${['tamang', 'hydro', 'kora', 'geo'].map((k) => `${GE.PEOPLE[k].h[0].toUpperCase()}`).join('')}` : `${ic('eye', 'xs')}${fmt.k(s.watchers)}`; - return `
-
${badge}
${ic(famIcon(s.fam), 'sm')}
-
${m}
${ml}
-
${esc(s.title)}
${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}${right}
- ${s.ride ? `` : ''}
`; +const avatarHTML = (k, cls = 'sm') => { const p = GE.PEOPLE[k] || { h: k, hue: 205 }; return `${esc((p.h || '?')[0].toUpperCase())}`; }; +GE.avatarHTML = avatarHTML; +const bandTag = (b) => `${GE.BANDS[b].name.toUpperCase()}`; +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 `
${esc(x.place)}
${x.dist < 60 ? 'NEAR YOU · ' : ''}${GE.feed.fmtDist(x.dist).toUpperCase()} AWAY · ${ago}
`; } +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 `
${x.wide ? `${stack}` : ''}${ic('threads', 'xs')}${fmt.k(n)} in room${sealed ? `${ic('seal', 'xs')}${sealed} sealed` : `${posts} posts`}
`; +} +function frameHTML(x, inner = '') { + const [w, h] = cvSize(x); + return `
+
${bandTag(x.band)}${GE.ARCH[x.arch].toUpperCase()}${x.live ? 'LIVE' : ''}${x.mine ? 'YOU' : ''}
+ ${inner}${x.tele ? `
${esc(x.tele())}
` : ''}
`; +} +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 = `
${x.big}${esc(x.unit)}
${esc(x.label)}
${GE.sparkPath(x.spark, 160, 34, B.c, 2)}
`; + if (x.readout) inner += `
${esc(typeof x.readout === 'function' ? x.readout() : x.readout)}
`; + if (x.arch === 'witness') inner += ``; + if (x.recon) inner += `
4DReconstruction
`; + 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 `
${avatarHTML(x.by)}@${esc(p.h)}${bd.map(([k, l]) => `${esc(l)}`).join('')}
`; })() : ''; + const cta = x.cta ? `` : ''; + const recon = x.recon ? `
23 observations9 hypotheses${ic('link', 'xs')}ledger verified
` : socHTML(x); + return `${frameHTML(x, inner)}
${who}
${esc(x.title)}
${whereHTML(x)}${recon}${cta}
`; +} +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 ? `
${lab}${esc(t)}${c ? `${c.map((z) => `${z}`).join('')}` : ''}
` : ''; + const ev = (x.ev || []).map((e, i) => ``).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 `
${ic('threads', 'xs')}ROOM${bandTag('inside')}${fmt.n(room.watching)} watching${ppl.map((k) => avatarHTML(k)).join('')}
+

${esc(x.title)}

${whereHTML(x)} +
${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])}
+ ${ev ? `
${ev}
` : ''} +
${room.posts.length} posts · ${sealed} sealed${notes ? ` · ${notes} note` : ''}Join the room ${ic('chevr', 'xs')}
`; } 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 = ` + 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 = `
-
${kick}·${fmt.hm(Date.now())} UTC
-

Now

-
${GE.SIT.list.length} situations– aircraft– vessels${fmt.n(GE.SATS.length)} satellites
+
GOD’S EYE
+
LIVE · ${kick} · ${GE.feed.hmsZ()}
+

Right now, at every altitude.

+
– aircraft– vessels${fmt.n(GE.SATS.length)} satellites408 cameras14 witnesses live
-
- ${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => ``).join('')} -
`; +
${ic('sparkles', 'xs')} CONCEPT · ILLUSTRATIVE DATA · PEOPLE AND POSTS ARE FICTIONAL
`; $('#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 ``; }).join(''); + $$('#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.name}${esc(B.alt)} · ${esc(B.sensor)}` })); } + 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); @@ -3692,9 +5181,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' }); @@ -3713,7 +5202,7 @@ NOWV.foldBack = function (id) { /* 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; @@ -3731,7 +5220,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 @@ -3740,9 +5229,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; @@ -3760,13 +5249,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 }); } }; @@ -3839,8 +5329,26 @@ function barsSVG(items, w) { items.forEach(([k, v], i) => { const y = i * 22; s += `${k}${Math.round(v * 100)}`; }); return `${s}`; } +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 ? `Live` : s.d4 ? `4D` : `Replay`; + const ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5); + el.innerHTML = `
${B.name.toUpperCase()}${live}${fmt.ago(Date.now() - s.updatedAt)}
+

${esc(s.title)}

${esc(s.place)} · ${fmt.ll(s.lat, s.lon, 2)} · ${GE.feed.fmtDist(GE.feed.distFromMe(s.lat, s.lon))} from you
+
${ppl.map((k) => GE.avatarHTML(k)).join('')}${fmt.n(room.watching)} watching${room.rate} posts/min
+
+
`; + 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 ? `Live` : s.d4 ? `4D` : `Replay`; const acts = [ @@ -3851,8 +5359,6 @@ SH.render = function (s) { ].join(''); const b = s.brief || {}; el.innerHTML = ` -
${GE.FAM[s.fam].name}${live}${fmt.ago(Date.now() - s.updatedAt)}
-

${esc(s.title)}

${esc(s.place)} · ${fmt.ll(s.lat, s.lon, 2)}
${ms.map((m) => `
${m.v}${m.u}
${m.l}
${m.s}
`).join('')}
${acts}
@@ -4046,7 +5552,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 ? '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 · ${fmt.dur((Date.now() - GE.live.t0) / 1000).slice(3)}`; $('#statusTime').textContent = 'return'; $('#status .dot').style.background = ''; } + else { stt.innerHTML = `${W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'} ${band} · ${alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M'}`; $('#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; @@ -4127,7 +5636,7 @@ 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(); }); }; // ---- 64-ride.js @@ -4279,32 +5788,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 = `
+ 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 = `
-
${ic('location', 'xs')}Simulated camera · ${SPOT.name}${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}
-
+
${ic('location', 'xs')}Simulated camera · ${SPOT.name}${{ 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]}
+ ${rt ? `
${ic('threads', 'xs')}Replying in ${esc(rt.short || rt.title.split(' — ')[0])}
` : ''} +
+
-
${[0.5, 1, 3].map((z) => ``).join('')}
-
+ ${m === 'look' || m === 'witness' ? `
${[0.5, 1, 3].map((z) => ``).join('')}
` : ''} +
${CAP.MODES.map(([k, l]) => ``).join('')}
- -
+ +
`; 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 @@ -4386,8 +5905,8 @@ CAP.ceremony = function (b, cv) {
${ic('check', 'xs')}
Bundle hash
…
${ic('check', 'xs')}
Signed · ECDSA P-256, key generated on device, non-extractable
…
-
-
+
+

None of this proves truth. It raises the cost of lying.

`; 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'); @@ -4401,7 +5920,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(); }); }; @@ -4565,12 +6084,20 @@ THR.init = async function () { /* ---------- list ---------- */ THR.render = function () { const v = $('#v-threads'); - v.innerHTML = `
${ic('seal', 'xs')} Signed evidence · open ledgers

Threads

${ic('sparkles', 'xs')} Concept · illustrative data
-
${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `${P[k].h[0].toUpperCase()}`).join('')}37 people reconstructing now
+ v.innerHTML = `
${ic('threads', 'xs')} CONVERSATIONS ON THE EVIDENCE

Rooms

${ic('sparkles', 'xs')} Concept · illustrative data
+
${['nadia', 'lin', 'aiko', 'tomas', 'ines'].map((k) => GE.avatarHTML(k)).join('')}${fmt.n(Object.values(GE.rooms.byId).reduce((a, r) => a + r.watching, 0) || 9400)} people in rooms now
+
Every situation has a room. Posts are Witness, Context, Question, Correction or Prediction, ordered by evidence — never by followers.
+

Live rooms

heating up
+

Reconstructions

4D · signed · open ledgers
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `

${esc(T.title)}

${esc(T.place)} · ${esc(T.date)}

4D
${T.obs.length}observations
${T.hyps.length}hypotheses
${THR.ledgers[T.id]?.length || '–'}ledger
${T.people.slice(0, 4).map((k) => `${P[k].h[0].toUpperCase()}`).join('')}
`; }).join('')}

Concept · illustrative data. Witness posts, handles and claims are fictional.

`; $('#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); @@ -4709,10 +6236,12 @@ YOU.render = function () { const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')]; v.innerHTML = `
${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}

You

-
Y

Your window

Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}
${ic('sparkles', 'xs')} Concept · illustrative data
-
${caps.length}captures
${holos.length}holograms
${Object.values(YOU.follows).filter(Boolean).length}following
${caps.length ? '100%' : '—'}verified
+
Y

Your eye

Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}
${ic('sparkles', 'xs')} Concept · illustrative data
+
${GE.feed.user.length}posts
${holos.length}holograms
${Object.values(YOU.follows).filter(Boolean).length}following
${caps.length ? '100%' : '—'}verified

Captures

sealed on this device
${caps.length ? caps.map((c, i) => `
${esc(c.place)}${fmt.utc(c.at)}
${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}
`).join('') : `
No captures yet — open Capture › Witness and press the shutter.
`}
+

Your posts

in rooms
+
${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 `
${ic(t === 'sealed-live' ? 'live' : t === 'unverified' ? 'gallery' : 'seal')}${esc(x.title)}${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)}
`; }).join('') : `
${ic('viewfinder')}Nothing posted yetCapture › Witness, Live or Upload, then attach it to a room
`}

Holograms

left in places
${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `
${ic('cube')}${esc(x.title)}${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}${x.empty ? '' : ``}
`).join('')}

Following

alerts
@@ -4733,6 +6262,7 @@ YOU.render = function () {

Renderer: 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.

`; 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 }); })); @@ -4770,6 +6300,419 @@ YOU.inspect = async function (i) { const prev = i > 0 ? GE.captures[i - 1].hash : '0'.repeat(64); await mark(5, prev === c.prev, `prev ${c.prev.slice(0, 32)}…`); }; +// ---- 68-room.js +/* 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) => ``).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]) => `${k === 'sealed' ? ic('seal', 'xs') : ''}${esc(l)}`).join(''); + const att = p.att ? `
${p.att.tier.startsWith('sealed') ? ic('seal', 'xs') : ''}${tierLabel(p.att)}${p.live ? 'LIVE' : ''}
` : ''; + const anc = [p.place ? `` : '', ``].join(''); + const ans = p.answered ? (() => { const a = room.posts.find((q) => q.id === p.answered); return a ? `
${ic('check', 'xs')}Answered by @${esc(GE.PEOPLE[a.by].h)} · ${GE.rooms.badges(room, a).map(([, l]) => esc(l)).join(' · ')}
` : ''; })() : p.kind === 'question' ? `
${ic('arrowup', 'xs')}Open question · pinned to the top until someone answers with evidence
` : ''; + const note = p.note ? `
${ic('people', 'xs')}Readers added context

${esc(p.note.text)} ${citeChips(p.note.cites)}

+
Raters who usually lean A${Math.round(p.note.helpfulA * 100)}%
Raters who usually lean B${Math.round(p.note.helpfulB * 100)}%
+
Why is this note showing?

A note only appears when people who usually disagree 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.

` : ''; + const rx = p.reacts || {}; const rxs = [['eye', rx.eye], ['heart', rx.heart], ['exclamation', rx.alert]].filter(([, n]) => n).map(([i, n]) => ``).join(''); + return `
+
${GE.avatarHTML(p.by)}
@${esc(who.h)}${pid}${fmt.ago(Date.now() - p.at)}
${bd}
${ic(KIND_ICON[p.kind], 'xs')}${K.name.toUpperCase()}
+
${esc(p.text)}${p.resolves ? `resolves ${esc(p.resolves)}` : ''}
+ ${att}
${anc}${citeChips(p.cites)}
${note}${ans} +
${rxs}${ic('bubble', 'xs')}${p.replies || 0}
`; +}; +RUI.synthHTML = function (room) { + const S = room.synth; const sec = (k, lab, cls) => (S[k] || []).length ? `
${lab}${S[k].map(([t, c]) => `

${esc(t)} ${citeChips(c)}

`).join('')}
` : `
${lab}

Nothing ${k} right now.

`; + const L = room.ledger; const last = room.synthEntry || L[L.length - 1]; + return `
${ic('sparkles', 'xs')}AI synthesis · every line cites a post or an observation
+
${sec('known', 'KNOWN', 'kn')}${sec('disputed', 'DISPUTED', 'di')}${sec('unknown', 'UNKNOWN', 'un')}
+
${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)}…` : ''}
`; +}; +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)} +
ORDER${[['evidence', 'Evidence first'], ['latest', 'Latest'], ['questions', 'Questions']].map(([k, l]) => ``).join('')}
+
${list.map((p) => RUI.postHTML(room, p)).join('')}
+
Sources in this room
${Object.entries(room.src || {}).map(([k, v]) => `
${k}${esc(v)}
`).join('')}
+

Posts are ordered by evidence, then time — never by followers. Concept · illustrative data; people and posts are fictional.

`; + 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: `${ic(KIND_ICON[p.kind], 'xs')}@${esc(who.h)}`, 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 = `
${Object.entries(GE.rooms.KINDS).map(([k, K]) => ``).join('')}
`; + 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 = '
WRITING

…

'; + 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); +}; + +// ---- 69-live.js +/* 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 = `
READYGo live as a sealed eye
+

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.

+
${near.map(({ s, km }) => `${ic('threads', 'xs')}${esc(s.short || s.title.split(' — ')[0])} · ${GE.feed.fmtDist(km)}`).join('')}
+
SIMULATED CAMERA · RENDERED SCENE · NO CAMERA OR MIC IS USED
`; + return; + } + host.innerHTML = `
LIVE 00:00${ic('eye', 'xs')}${LV.viewers}${ic('seal', 'xs')}SEALED LIVE
+
${ic('threads', 'xs')}In the ${esc(LV.sit.short || LV.sit.title)} room · ${GE.feed.fmtDist(RM.kmBetween(SPOTm.lat, SPOTm.lon, LV.sit.lat, LV.sit.lon))}
+
+
`; + 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) => `
#${String(s.i + 1).padStart(3, '0')}${s.hash.slice(0, 8)}${ic('seal', 'xs')}${s.sig ? 'signed' : 'hashed'}
`).join(''); + host.innerHTML = `${blocks}${LV.segs.length ? '' : ''}
#${String(LV.segs.length + 1).padStart(3, '0')}
sealing…
`; + 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 ? `
${GE.avatarHTML(e.join)}@${esc(GE.PEOPLE[e.join].h)} joined
` : `
${GE.avatarHTML(e.msg[0])}
@${esc(GE.PEOPLE[e.msg[0]].h)}${esc(e.msg[1])}
`).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 = `
Add a photo or video of a placeUNVERIFIED
+

Uploads weren’t sealed when they were taken, so they carry an Unverified label everywhere. A hypothesis can cite one, but it can never become an observation.

+ + +

READ LOCALLY WITH FILEREADER · NOTHING LEAVES THIS PAGE

`; + $('#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 = `
Your uploadUNVERIFIED
+
FILE${esc(f.name)} · ${fmt.k(f.size / 1024)} KB
CAMERA${esc([ex.make, ex.model].filter(Boolean).join(' ') || '—')}
TAKEN${ex.date ? esc(ex.date) : '—'}
GPS${ex.lat != null ? GE.feed.llz(ex.lat, ex.lon) + ' · from EXIF' : 'none in file — place it'}
+
${ic('location', 'sm')}
+
When
+
`; + 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 = `
+
${c.tier === 'unverified' ? '' : ic('seal', 'xs')}${tier}

Where does this belong?

${GE.feed.llz(c.lat, c.lon)} · ${fmt.hm(c.at)} UTC${c.hash ? ' · ' + c.hash.slice(0, 10) + '…' : ''}

+
Suggested from where and when it was taken
+
${opts.map(({ s, km }) => ``).join('')} +
+
Post as
${Object.entries(GE.rooms.KINDS).map(([k, K]) => ``).join('')}
+ +
Share location as
${[['exact', 'Exact'], ['100', '100 m'], ['1000', '1 km']].map(([k, l]) => ``).join('')}
+

${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.'}

+
${ic('cube')}Leave it here as a hologramPeople who come to this spot later can find it
+

${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.

+
`; + 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); +}; + // ---- 90-main.js /* Boot, render loop, adaptive-DPR governor, cold open. */ const HASH = (() => { const o = {}; try { (location.hash || '').slice(1).split('&').forEach((kv) => { const [k, v] = kv.split('='); if (k) o[decodeURIComponent(k)] = v == null ? true : decodeURIComponent(v); }); } catch {} return o; })(); @@ -4807,7 +6750,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; @@ -4823,36 +6767,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 ---------- */ diff --git a/mobile/vision/src/app.css b/mobile/vision/src/app.css index 9dbe4726b..1dc9e00bd 100644 --- a/mobile/vision/src/app.css +++ b/mobile/vision/src/app.css @@ -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; } diff --git a/mobile/vision/src/app.html b/mobile/vision/src/app.html index dc0cadfb3..345de35cc 100644 --- a/mobile/vision/src/app.html +++ b/mobile/vision/src/app.html @@ -129,6 +129,8 @@
+ +
@@ -142,7 +144,7 @@ - + @@ -151,14 +153,17 @@
+
Concept · illustrative data
- -
God’s Eye
-
The window to the world.
+
ORBIT36,000–400 KM
SKY15 KM
GROUND0 M
INSIDEA WITNESS
+
ALT 35,786 KM
+
+
WITNESS · SEALED · ±4 M
+
GOD’S EYE
From orbit to the witness.
diff --git a/mobile/vision/src/js/10-data.js b/mobile/vision/src/js/10-data.js index 2b3f9055a..cf0051c2f 100644 --- a/mobile/vision/src/js/10-data.js +++ b/mobile/vision/src/js/10-data.js @@ -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 }); diff --git a/mobile/vision/src/js/30-world.js b/mobile/vision/src/js/30-world.js index 8781d7cd6..6c0b5919f 100644 --- a/mobile/vision/src/js/30-world.js +++ b/mobile/vision/src/js/30-world.js @@ -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]); diff --git a/mobile/vision/src/js/36-paint.js b/mobile/vision/src/js/36-paint.js new file mode 100644 index 000000000..4ebb40d2d --- /dev/null +++ b/mobile/vision/src/js/36-paint.js @@ -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]; diff --git a/mobile/vision/src/js/50-situations.js b/mobile/vision/src/js/50-situations.js index a1d5086e5..3bbb3b24c 100644 --- a/mobile/vision/src/js/50-situations.js +++ b/mobile/vision/src/js/50-situations.js @@ -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: () => [`T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}`, '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: () => [`T+${fmt.dur((Date.now() - LAUNCH_T0) / 1000)}`, '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; diff --git a/mobile/vision/src/js/54-rooms.js b/mobile/vision/src/js/54-rooms.js new file mode 100644 index 000000000..1b18f29b2 --- /dev/null +++ b/mobile/vision/src/js/54-rooms.js @@ -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 }; +}; diff --git a/mobile/vision/src/js/55-feed.js b/mobile/vision/src/js/55-feed.js new file mode 100644 index 000000000..ab7f9cff6 --- /dev/null +++ b/mobile/vision/src/js/55-feed.js @@ -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; }; diff --git a/mobile/vision/src/js/60-shell.js b/mobile/vision/src/js/60-shell.js index 4d0d4ffae..62fb835ce 100644 --- a/mobile/vision/src/js/60-shell.js +++ b/mobile/vision/src/js/60-shell.js @@ -67,11 +67,17 @@ APP.updateChrome = function () { /* ---------- accessory ---------- */ APP.renderAccessory = function () { - const s = GE.SIT.byId[APP.following]; if (!s) return; - const html = `
${esc(GE.CYCLONE.name)}
${esc(s.accessory ? s.accessory() : '')}
${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}`; - $('#acc').innerHTML = html; - $('#accMini').innerHTML = `
${esc(GE.CYCLONE.name)}
${esc(s.accessory ? s.accessory() : '')}
`; + // 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 = `
${esc(ttl)}
${esc(sub)}
`; + 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 = `
${esc(ttl)}
${esc(sub)}
`; 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: `${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: `${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: `${esc(GE.world.sel.title)}`, 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) diff --git a/mobile/vision/src/js/61-now.js b/mobile/vision/src/js/61-now.js index 86ebf1bf3..d92752a9b 100644 --- a/mobile/vision/src/js/61-now.js +++ b/mobile/vision/src/js/61-now.js @@ -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 ? `4DThread` : s.live ? `Live` : `${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}`; - const right = s.thread ? `${['tamang', 'hydro', 'kora', 'geo'].map((k) => `${GE.PEOPLE[k].h[0].toUpperCase()}`).join('')}` : `${ic('eye', 'xs')}${fmt.k(s.watchers)}`; - return `
-
${badge}
${ic(famIcon(s.fam), 'sm')}
-
${m}
${ml}
-
${esc(s.title)}
${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}${right}
- ${s.ride ? `` : ''}
`; +const avatarHTML = (k, cls = 'sm') => { const p = GE.PEOPLE[k] || { h: k, hue: 205 }; return `${esc((p.h || '?')[0].toUpperCase())}`; }; +GE.avatarHTML = avatarHTML; +const bandTag = (b) => `${GE.BANDS[b].name.toUpperCase()}`; +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 `
${esc(x.place)}
${x.dist < 60 ? 'NEAR YOU · ' : ''}${GE.feed.fmtDist(x.dist).toUpperCase()} AWAY · ${ago}
`; } +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 `
${x.wide ? `${stack}` : ''}${ic('threads', 'xs')}${fmt.k(n)} in room${sealed ? `${ic('seal', 'xs')}${sealed} sealed` : `${posts} posts`}
`; +} +function frameHTML(x, inner = '') { + const [w, h] = cvSize(x); + return `
+
${bandTag(x.band)}${GE.ARCH[x.arch].toUpperCase()}${x.live ? 'LIVE' : ''}${x.mine ? 'YOU' : ''}
+ ${inner}${x.tele ? `
${esc(x.tele())}
` : ''}
`; +} +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 = `
${x.big}${esc(x.unit)}
${esc(x.label)}
${GE.sparkPath(x.spark, 160, 34, B.c, 2)}
`; + if (x.readout) inner += `
${esc(typeof x.readout === 'function' ? x.readout() : x.readout)}
`; + if (x.arch === 'witness') inner += ``; + if (x.recon) inner += `
4DReconstruction
`; + 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 `
${avatarHTML(x.by)}@${esc(p.h)}${bd.map(([k, l]) => `${esc(l)}`).join('')}
`; })() : ''; + const cta = x.cta ? `` : ''; + const recon = x.recon ? `
23 observations9 hypotheses${ic('link', 'xs')}ledger verified
` : socHTML(x); + return `${frameHTML(x, inner)}
${who}
${esc(x.title)}
${whereHTML(x)}${recon}${cta}
`; +} +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 ? `
${lab}${esc(t)}${c ? `${c.map((z) => `${z}`).join('')}` : ''}
` : ''; + const ev = (x.ev || []).map((e, i) => ``).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 `
${ic('threads', 'xs')}ROOM${bandTag('inside')}${fmt.n(room.watching)} watching${ppl.map((k) => avatarHTML(k)).join('')}
+

${esc(x.title)}

${whereHTML(x)} +
${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])}
+ ${ev ? `
${ev}
` : ''} +
${room.posts.length} posts · ${sealed} sealed${notes ? ` · ${notes} note` : ''}Join the room ${ic('chevr', 'xs')}
`; } 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 = ` + 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 = `
-
${kick}·${fmt.hm(Date.now())} UTC
-

Now

-
${GE.SIT.list.length} situations– aircraft– vessels${fmt.n(GE.SATS.length)} satellites
+
GOD’S EYE
+
LIVE · ${kick} · ${GE.feed.hmsZ()}
+

Right now, at every altitude.

+
– aircraft– vessels${fmt.n(GE.SATS.length)} satellites408 cameras14 witnesses live
-
- ${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => ``).join('')} -
`; +
${ic('sparkles', 'xs')} CONCEPT · ILLUSTRATIVE DATA · PEOPLE AND POSTS ARE FICTIONAL
`; $('#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 ``; }).join(''); + $$('#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.name}${esc(B.alt)} · ${esc(B.sensor)}` })); } + 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' }); diff --git a/mobile/vision/src/js/62-sheet.js b/mobile/vision/src/js/62-sheet.js index ec8b0d8a8..f2b7335dd 100644 --- a/mobile/vision/src/js/62-sheet.js +++ b/mobile/vision/src/js/62-sheet.js @@ -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 += `${k}${Math.round(v * 100)}`; }); return `${s}`; } +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 ? `Live` : s.d4 ? `4D` : `Replay`; + const ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5); + el.innerHTML = `
${B.name.toUpperCase()}${live}${fmt.ago(Date.now() - s.updatedAt)}
+

${esc(s.title)}

${esc(s.place)} · ${fmt.ll(s.lat, s.lon, 2)} · ${GE.feed.fmtDist(GE.feed.distFromMe(s.lat, s.lon))} from you
+
${ppl.map((k) => GE.avatarHTML(k)).join('')}${fmt.n(room.watching)} watching${room.rate} posts/min
+
+
`; + 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 ? `Live` : s.d4 ? `4D` : `Replay`; const acts = [ @@ -139,8 +158,6 @@ SH.render = function (s) { ].join(''); const b = s.brief || {}; el.innerHTML = ` -
${GE.FAM[s.fam].name}${live}${fmt.ago(Date.now() - s.updatedAt)}
-

${esc(s.title)}

${esc(s.place)} · ${fmt.ll(s.lat, s.lon, 2)}
${ms.map((m) => `
${m.v}${m.u}
${m.l}
${m.s}
`).join('')}
${acts}
diff --git a/mobile/vision/src/js/63-globe-ui.js b/mobile/vision/src/js/63-globe-ui.js index c40754a3e..cc73b6066 100644 --- a/mobile/vision/src/js/63-globe-ui.js +++ b/mobile/vision/src/js/63-globe-ui.js @@ -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 ? '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 · ${fmt.dur((Date.now() - GE.live.t0) / 1000).slice(3)}`; $('#statusTime').textContent = 'return'; $('#status .dot').style.background = ''; } + else { stt.innerHTML = `${W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'} ${band} · ${alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M'}`; $('#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(); }); }; diff --git a/mobile/vision/src/js/65-capture.js b/mobile/vision/src/js/65-capture.js index 9a9ba8674..44eabfd6a 100644 --- a/mobile/vision/src/js/65-capture.js +++ b/mobile/vision/src/js/65-capture.js @@ -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 = `
+ 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 = `
-
${ic('location', 'xs')}Simulated camera · ${SPOT.name}${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}
-
+
${ic('location', 'xs')}Simulated camera · ${SPOT.name}${{ 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]}
+ ${rt ? `
${ic('threads', 'xs')}Replying in ${esc(rt.short || rt.title.split(' — ')[0])}
` : ''} +
+
-
${[0.5, 1, 3].map((z) => ``).join('')}
-
+ ${m === 'look' || m === 'witness' ? `
${[0.5, 1, 3].map((z) => ``).join('')}
` : ''} +
${CAP.MODES.map(([k, l]) => ``).join('')}
- -
+ +
`; 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) {
${ic('check', 'xs')}
Bundle hash
…
${ic('check', 'xs')}
Signed · ECDSA P-256, key generated on device, non-extractable
…
-
-
+
+

None of this proves truth. It raises the cost of lying.

`; 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(); }); }; diff --git a/mobile/vision/src/js/66-threads.js b/mobile/vision/src/js/66-threads.js index 3ba4a986c..8ffcd3528 100644 --- a/mobile/vision/src/js/66-threads.js +++ b/mobile/vision/src/js/66-threads.js @@ -156,12 +156,20 @@ THR.init = async function () { /* ---------- list ---------- */ THR.render = function () { const v = $('#v-threads'); - v.innerHTML = `
${ic('seal', 'xs')} Signed evidence · open ledgers

Threads

${ic('sparkles', 'xs')} Concept · illustrative data
-
${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `${P[k].h[0].toUpperCase()}`).join('')}37 people reconstructing now
+ v.innerHTML = `
${ic('threads', 'xs')} CONVERSATIONS ON THE EVIDENCE

Rooms

${ic('sparkles', 'xs')} Concept · illustrative data
+
${['nadia', 'lin', 'aiko', 'tomas', 'ines'].map((k) => GE.avatarHTML(k)).join('')}${fmt.n(Object.values(GE.rooms.byId).reduce((a, r) => a + r.watching, 0) || 9400)} people in rooms now
+
Every situation has a room. Posts are Witness, Context, Question, Correction or Prediction, ordered by evidence — never by followers.
+

Live rooms

heating up
+

Reconstructions

4D · signed · open ledgers
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `

${esc(T.title)}

${esc(T.place)} · ${esc(T.date)}

4D
${T.obs.length}observations
${T.hyps.length}hypotheses
${THR.ledgers[T.id]?.length || '–'}ledger
${T.people.slice(0, 4).map((k) => `${P[k].h[0].toUpperCase()}`).join('')}
`; }).join('')}

Concept · illustrative data. Witness posts, handles and claims are fictional.

`; $('#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); diff --git a/mobile/vision/src/js/67-you.js b/mobile/vision/src/js/67-you.js index d40cf1569..37f23ff80 100644 --- a/mobile/vision/src/js/67-you.js +++ b/mobile/vision/src/js/67-you.js @@ -6,10 +6,12 @@ YOU.render = function () { const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')]; v.innerHTML = `
${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}

You

-
Y

Your window

Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}
${ic('sparkles', 'xs')} Concept · illustrative data
-
${caps.length}captures
${holos.length}holograms
${Object.values(YOU.follows).filter(Boolean).length}following
${caps.length ? '100%' : '—'}verified
+
Y

Your eye

Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}
${ic('sparkles', 'xs')} Concept · illustrative data
+
${GE.feed.user.length}posts
${holos.length}holograms
${Object.values(YOU.follows).filter(Boolean).length}following
${caps.length ? '100%' : '—'}verified

Captures

sealed on this device
${caps.length ? caps.map((c, i) => `
${esc(c.place)}${fmt.utc(c.at)}
${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}
`).join('') : `
No captures yet — open Capture › Witness and press the shutter.
`}
+

Your posts

in rooms
+
${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 `
${ic(t === 'sealed-live' ? 'live' : t === 'unverified' ? 'gallery' : 'seal')}${esc(x.title)}${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)}
`; }).join('') : `
${ic('viewfinder')}Nothing posted yetCapture › Witness, Live or Upload, then attach it to a room
`}

Holograms

left in places
${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `
${ic('cube')}${esc(x.title)}${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}${x.empty ? '' : ``}
`).join('')}

Following

alerts
@@ -30,6 +32,7 @@ YOU.render = function () {

Renderer: 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.

`; 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 }); })); diff --git a/mobile/vision/src/js/68-room.js b/mobile/vision/src/js/68-room.js new file mode 100644 index 000000000..cd3455db5 --- /dev/null +++ b/mobile/vision/src/js/68-room.js @@ -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) => ``).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]) => `${k === 'sealed' ? ic('seal', 'xs') : ''}${esc(l)}`).join(''); + const att = p.att ? `
${p.att.tier.startsWith('sealed') ? ic('seal', 'xs') : ''}${tierLabel(p.att)}${p.live ? 'LIVE' : ''}
` : ''; + const anc = [p.place ? `` : '', ``].join(''); + const ans = p.answered ? (() => { const a = room.posts.find((q) => q.id === p.answered); return a ? `
${ic('check', 'xs')}Answered by @${esc(GE.PEOPLE[a.by].h)} · ${GE.rooms.badges(room, a).map(([, l]) => esc(l)).join(' · ')}
` : ''; })() : p.kind === 'question' ? `
${ic('arrowup', 'xs')}Open question · pinned to the top until someone answers with evidence
` : ''; + const note = p.note ? `
${ic('people', 'xs')}Readers added context

${esc(p.note.text)} ${citeChips(p.note.cites)}

+
Raters who usually lean A${Math.round(p.note.helpfulA * 100)}%
Raters who usually lean B${Math.round(p.note.helpfulB * 100)}%
+
Why is this note showing?

A note only appears when people who usually disagree 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.

` : ''; + const rx = p.reacts || {}; const rxs = [['eye', rx.eye], ['heart', rx.heart], ['exclamation', rx.alert]].filter(([, n]) => n).map(([i, n]) => ``).join(''); + return `
+
${GE.avatarHTML(p.by)}
@${esc(who.h)}${pid}${fmt.ago(Date.now() - p.at)}
${bd}
${ic(KIND_ICON[p.kind], 'xs')}${K.name.toUpperCase()}
+
${esc(p.text)}${p.resolves ? `resolves ${esc(p.resolves)}` : ''}
+ ${att}
${anc}${citeChips(p.cites)}
${note}${ans} +
${rxs}${ic('bubble', 'xs')}${p.replies || 0}
`; +}; +RUI.synthHTML = function (room) { + const S = room.synth; const sec = (k, lab, cls) => (S[k] || []).length ? `
${lab}${S[k].map(([t, c]) => `

${esc(t)} ${citeChips(c)}

`).join('')}
` : `
${lab}

Nothing ${k} right now.

`; + const L = room.ledger; const last = room.synthEntry || L[L.length - 1]; + return `
${ic('sparkles', 'xs')}AI synthesis · every line cites a post or an observation
+
${sec('known', 'KNOWN', 'kn')}${sec('disputed', 'DISPUTED', 'di')}${sec('unknown', 'UNKNOWN', 'un')}
+
${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)}…` : ''}
`; +}; +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)} +
ORDER${[['evidence', 'Evidence first'], ['latest', 'Latest'], ['questions', 'Questions']].map(([k, l]) => ``).join('')}
+
${list.map((p) => RUI.postHTML(room, p)).join('')}
+
Sources in this room
${Object.entries(room.src || {}).map(([k, v]) => `
${k}${esc(v)}
`).join('')}
+

Posts are ordered by evidence, then time — never by followers. Concept · illustrative data; people and posts are fictional.

`; + 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: `${ic(KIND_ICON[p.kind], 'xs')}@${esc(who.h)}`, 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 = `
${Object.entries(GE.rooms.KINDS).map(([k, K]) => ``).join('')}
`; + 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 = '
WRITING

…

'; + 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); +}; diff --git a/mobile/vision/src/js/69-live.js b/mobile/vision/src/js/69-live.js new file mode 100644 index 000000000..124781470 --- /dev/null +++ b/mobile/vision/src/js/69-live.js @@ -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 = `
READYGo live as a sealed eye
+

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.

+
${near.map(({ s, km }) => `${ic('threads', 'xs')}${esc(s.short || s.title.split(' — ')[0])} · ${GE.feed.fmtDist(km)}`).join('')}
+
SIMULATED CAMERA · RENDERED SCENE · NO CAMERA OR MIC IS USED
`; + return; + } + host.innerHTML = `
LIVE 00:00${ic('eye', 'xs')}${LV.viewers}${ic('seal', 'xs')}SEALED LIVE
+
${ic('threads', 'xs')}In the ${esc(LV.sit.short || LV.sit.title)} room · ${GE.feed.fmtDist(RM.kmBetween(SPOTm.lat, SPOTm.lon, LV.sit.lat, LV.sit.lon))}
+
+
`; + 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) => `
#${String(s.i + 1).padStart(3, '0')}${s.hash.slice(0, 8)}${ic('seal', 'xs')}${s.sig ? 'signed' : 'hashed'}
`).join(''); + host.innerHTML = `${blocks}${LV.segs.length ? '' : ''}
#${String(LV.segs.length + 1).padStart(3, '0')}
sealing…
`; + 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 ? `
${GE.avatarHTML(e.join)}@${esc(GE.PEOPLE[e.join].h)} joined
` : `
${GE.avatarHTML(e.msg[0])}
@${esc(GE.PEOPLE[e.msg[0]].h)}${esc(e.msg[1])}
`).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 = `
Add a photo or video of a placeUNVERIFIED
+

Uploads weren’t sealed when they were taken, so they carry an Unverified label everywhere. A hypothesis can cite one, but it can never become an observation.

+ + +

READ LOCALLY WITH FILEREADER · NOTHING LEAVES THIS PAGE

`; + $('#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 = `
Your uploadUNVERIFIED
+
FILE${esc(f.name)} · ${fmt.k(f.size / 1024)} KB
CAMERA${esc([ex.make, ex.model].filter(Boolean).join(' ') || '—')}
TAKEN${ex.date ? esc(ex.date) : '—'}
GPS${ex.lat != null ? GE.feed.llz(ex.lat, ex.lon) + ' · from EXIF' : 'none in file — place it'}
+
${ic('location', 'sm')}
+
When
+
`; + 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 = `
+
${c.tier === 'unverified' ? '' : ic('seal', 'xs')}${tier}

Where does this belong?

${GE.feed.llz(c.lat, c.lon)} · ${fmt.hm(c.at)} UTC${c.hash ? ' · ' + c.hash.slice(0, 10) + '…' : ''}

+
Suggested from where and when it was taken
+
${opts.map(({ s, km }) => ``).join('')} +
+
Post as
${Object.entries(GE.rooms.KINDS).map(([k, K]) => ``).join('')}
+ +
Share location as
${[['exact', 'Exact'], ['100', '100 m'], ['1000', '1 km']].map(([k, l]) => ``).join('')}
+

${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.'}

+
${ic('cube')}Leave it here as a hologramPeople who come to this spot later can find it
+

${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.

+
`; + 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); +}; diff --git a/mobile/vision/src/js/90-main.js b/mobile/vision/src/js/90-main.js index 5a431c6f5..e70785ab8 100644 --- a/mobile/vision/src/js/90-main.js +++ b/mobile/vision/src/js/90-main.js @@ -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 ---------- */