mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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`.
|
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 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 box = bb || sc.bounds;
|
||||||
const colors = [
|
const colors = [
|
||||||
'rgba(255,250,225,1)',
|
'rgba(255,255,255,1)',
|
||||||
'rgba(255,214,120,0.85)',
|
'rgba(255,226,122,0.75)',
|
||||||
'rgba(255,150,80,0.7)',
|
'rgba(255,150,70,0.6)',
|
||||||
'rgba(240,90,160,0.55)',
|
'rgba(255,85,150,0.5)',
|
||||||
'rgba(170,90,255,0.45)',
|
'rgba(176,107,255,0.42)',
|
||||||
'rgba(110,90,255,0.35)',
|
'rgba(106,107,255,0.34)',
|
||||||
];
|
];
|
||||||
// Sample at the product's native ~8 km grid, one dot per cell.
|
// Sample at the product's native ~8 km grid, one dot per cell.
|
||||||
const kmPerPx =
|
const kmPerPx =
|
||||||
@@ -722,7 +722,7 @@ const SCENE_DRAW = {
|
|||||||
continue;
|
continue;
|
||||||
const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]);
|
const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]);
|
||||||
const r =
|
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);
|
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),
|
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 pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
|
||||||
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
||||||
return {
|
return {
|
||||||
@@ -425,6 +438,7 @@ export function parseTcm(text) {
|
|||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
radii,
|
radii,
|
||||||
|
forecast,
|
||||||
pressureHpa: pressure ? Number(pressure) : null,
|
pressureHpa: pressure ? Number(pressure) : null,
|
||||||
windKt: maxWind ? Number(maxWind) : 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.
|
// Semantic zoom for words too: from orbit only the headline layers.
|
||||||
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
|
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
|
||||||
if (layer.id === 'lightning') continue; // the glow is the label
|
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);
|
const rank = LABEL_PRIORITY.indexOf(layer.id);
|
||||||
for (let i = 0; i < glyphs.length; i++) {
|
for (let i = 0; i < glyphs.length; i++) {
|
||||||
const bb = glyphs.get(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;
|
if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selected?.layer === layer.id && selected?.key === bb.id.key;
|
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);
|
const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
layer,
|
layer,
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ class CycloneLayer extends Layer {
|
|||||||
if (this.fills) this.root.remove(this.fills);
|
if (this.fills) this.root.remove(this.fills);
|
||||||
this.fills = null;
|
this.fills = null;
|
||||||
const instances = [];
|
const instances = [];
|
||||||
|
let relined = false;
|
||||||
const poly = (ring, css, alpha, height) => {
|
const poly = (ring, css, alpha, height) => {
|
||||||
if (!ring || ring.length < 4) return;
|
if (!ring || ring.length < 4) return;
|
||||||
instances.push(
|
instances.push(
|
||||||
@@ -368,6 +369,32 @@ class CycloneLayer extends Layer {
|
|||||||
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
||||||
const tcm = radiiFor(s, this.tcm?.data);
|
const tcm = radiiFor(s, this.tcm?.data);
|
||||||
if (tcm) s.radii = tcm.radii;
|
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)) {
|
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
|
||||||
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
||||||
poly(
|
poly(
|
||||||
@@ -378,6 +405,7 @@ class CycloneLayer extends Layer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (relined) this._lines(storms);
|
||||||
if (!instances.length) return;
|
if (!instances.length) return;
|
||||||
this.fills = this.root.add(
|
this.fills = this.root.add(
|
||||||
new Cesium.Primitive({
|
new Cesium.Primitive({
|
||||||
|
|||||||
@@ -412,7 +412,17 @@ export async function buildSituations(engine, { observer } = {}) {
|
|||||||
'Storm';
|
'Storm';
|
||||||
const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null;
|
const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null;
|
||||||
const lastTau = s.forecast?.at(-1)?.tauHours ?? 0;
|
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 r34 = radii?.find((r) => r.kt === 34);
|
||||||
const cat = stormCategory(s.windKt);
|
const cat = stormCategory(s.windKt);
|
||||||
return card(
|
return card(
|
||||||
@@ -485,7 +495,7 @@ export async function buildSituations(engine, { observer } = {}) {
|
|||||||
lat: s.lat,
|
lat: s.lat,
|
||||||
windKt: s.windKt,
|
windKt: s.windKt,
|
||||||
cone: s.cone,
|
cone: s.cone,
|
||||||
forecast: s.forecast || [],
|
forecast,
|
||||||
pastTrack: s.pastTrack || null,
|
pastTrack: s.pastTrack || null,
|
||||||
track: s.track || null,
|
track: s.track || null,
|
||||||
radii,
|
radii,
|
||||||
|
|||||||
Reference in new issue
Block a user