mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(mobile): weather and storm layers, card compositor, pins and nearest eye
Radar (nowCOAST, with replay), satellite IR clouds, lightning density, NHC cones and 34/50/64-kt wind radii, 10 m and 250 hPa wind on the repo's GPU renderer, air quality, GNSS interference and transit. situations() now yields 25-30 cards across orbit, sky and ground with band, archetype, telemetry, headline, real series and live camera media; genres whose feed fails are omitted and reported by omitted(). thumbnail() composites each card in an offscreen canvas from the layers that describe it. addPin() draws posts, witnesses and live view frustums; nearestEye() finds the closest public camera. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
6 files changed
+73
-9
No files matched your search
@@ -101,3 +101,13 @@ globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
|
||||
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.
|
||||
|
||||
When NHC has published an advisory but its cone geometry is still pending, the cyclone layer and cards take the forecast positions from the advisory text, so a track is still drawn but there is no cone.
|
||||
|
||||
## Dev harnesses
|
||||
|
||||
- `mobile/globe/dev.html` shows the globe full-screen and exposes `window.globe`. Its labels show `detail`.
|
||||
- `mobile/globe/contact.html` is the contact sheet: it calls `situations()`, then `thumbnail()` for every card.
|
||||
- `?lat=&lon=` sets the viewer.
|
||||
- `?only=cyclone,eye` filters cards by id prefix.
|
||||
- `?w=330` sets the card width.
|
||||
@@ -685,12 +685,12 @@ const SCENE_DRAW = {
|
||||
);
|
||||
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)',
|
||||
'rgba(255,255,255,1)',
|
||||
'rgba(255,226,122,0.75)',
|
||||
'rgba(255,150,70,0.6)',
|
||||
'rgba(255,85,150,0.5)',
|
||||
'rgba(176,107,255,0.42)',
|
||||
'rgba(106,107,255,0.34)',
|
||||
];
|
||||
// Sample at the product's native ~8 km grid, one dot per cell.
|
||||
const kmPerPx =
|
||||
@@ -722,7 +722,7 @@ const SCENE_DRAW = {
|
||||
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);
|
||||
dotR * (age === 0 ? 0.85 : 1.6 - age * 0.1) * (0.75 + lvl * 0.2);
|
||||
g.drawImage(sprite, sx - r, sy - r, r * 2, r * 2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -418,6 +418,19 @@ export function parseTcm(text) {
|
||||
quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM),
|
||||
});
|
||||
}
|
||||
// Forecast and outlook positions (used when the cone geometry is pending).
|
||||
const forecast = [];
|
||||
for (const m of text.matchAll(
|
||||
/(?:FORECAST|OUTLOOK) VALID\s+(\d\d)\/(\d\d)(\d\d)Z\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])[\s\S]*?MAX WIND\s+(\d+) KT/g,
|
||||
)) {
|
||||
forecast.push({
|
||||
day: Number(m[1]),
|
||||
hourZ: Number(m[2]),
|
||||
lat: Number(m[4]) * (m[5] === 'S' ? -1 : 1),
|
||||
lon: Number(m[6]) * (m[7] === 'W' ? -1 : 1),
|
||||
windKt: Number(m[8]),
|
||||
});
|
||||
}
|
||||
const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
|
||||
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
||||
return {
|
||||
@@ -425,6 +438,7 @@ export function parseTcm(text) {
|
||||
lat,
|
||||
lon,
|
||||
radii,
|
||||
forecast,
|
||||
pressureHpa: pressure ? Number(pressure) : null,
|
||||
windKt: maxWind ? Number(maxWind) : null,
|
||||
};
|
||||
|
||||
@@ -969,6 +969,7 @@ export async function createGlobe(container, options = {}) {
|
||||
// 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 crowd = layer.id === 'transit'; // hundreds of buses: selected only
|
||||
const rank = LABEL_PRIORITY.indexOf(layer.id);
|
||||
for (let i = 0; i < glyphs.length; i++) {
|
||||
const bb = glyphs.get(i);
|
||||
@@ -977,6 +978,7 @@ export async function createGlobe(container, options = {}) {
|
||||
if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue;
|
||||
const isSelected =
|
||||
selected?.layer === layer.id && selected?.key === bb.id.key;
|
||||
if (crowd && !isSelected) continue;
|
||||
const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h);
|
||||
candidates.push({
|
||||
layer,
|
||||
|
||||
@@ -345,6 +345,7 @@ class CycloneLayer extends Layer {
|
||||
if (this.fills) this.root.remove(this.fills);
|
||||
this.fills = null;
|
||||
const instances = [];
|
||||
let relined = false;
|
||||
const poly = (ring, css, alpha, height) => {
|
||||
if (!ring || ring.length < 4) return;
|
||||
instances.push(
|
||||
@@ -368,6 +369,32 @@ class CycloneLayer extends Layer {
|
||||
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
||||
const tcm = radiiFor(s, this.tcm?.data);
|
||||
if (tcm) s.radii = tcm.radii;
|
||||
// Cone geometry pending: the advisory text still has the positions.
|
||||
if (tcm?.forecast?.length && !s.forecast?.length) {
|
||||
const t0 = new Date(s.t);
|
||||
s.forecast = tcm.forecast.map((p) => {
|
||||
let at = Date.UTC(
|
||||
t0.getUTCFullYear(),
|
||||
t0.getUTCMonth(),
|
||||
p.day,
|
||||
p.hourZ,
|
||||
);
|
||||
if (at < s.t - 15 * 86_400_000)
|
||||
at = Date.UTC(
|
||||
t0.getUTCFullYear(),
|
||||
t0.getUTCMonth() + 1,
|
||||
p.day,
|
||||
p.hourZ,
|
||||
);
|
||||
return {
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
windKt: p.windKt,
|
||||
tauHours: Math.round((at - s.t) / 3_600_000),
|
||||
};
|
||||
});
|
||||
relined = true;
|
||||
}
|
||||
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
|
||||
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
||||
poly(
|
||||
@@ -378,6 +405,7 @@ class CycloneLayer extends Layer {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (relined) this._lines(storms);
|
||||
if (!instances.length) return;
|
||||
this.fills = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
|
||||
@@ -412,7 +412,17 @@ 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;
|
||||
const radii = radiiFor(s, tcm)?.radii || null;
|
||||
const adv = radiiFor(s, tcm);
|
||||
const radii = adv?.radii || null;
|
||||
// NHC sometimes publishes an advisory before its cone geometry: the
|
||||
// TCM text still carries the forecast positions.
|
||||
const forecast = s.forecast?.length
|
||||
? s.forecast
|
||||
: (adv?.forecast || []).map((p) => ({
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
windKt: p.windKt,
|
||||
}));
|
||||
const r34 = radii?.find((r) => r.kt === 34);
|
||||
const cat = stormCategory(s.windKt);
|
||||
return card(
|
||||
@@ -485,7 +495,7 @@ export async function buildSituations(engine, { observer } = {}) {
|
||||
lat: s.lat,
|
||||
windKt: s.windKt,
|
||||
cone: s.cone,
|
||||
forecast: s.forecast || [],
|
||||
forecast,
|
||||
pastTrack: s.pastTrack || null,
|
||||
track: s.track || null,
|
||||
radii,
|
||||
|
||||
Reference in new issue
Block a user