mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Three-model review (astra, sol, fable), round 2: 29 of 30 findings
applied, one of them in part.
- pt: FIRMS «mais recente há», CCTV «cruzamentos», degraded radio
directory, «{layers}: ativação recente» for any layer list, «Sem
imagem», NIVELADO, AJUSTA-SE, quoted Provider Settings, V/S kept on
the ADS-B card, no-break space in «(24 h)» / «(30 d)», and more
(see docs/translations/pt.md).
- Malformed cyclone snapshot is now a catalog message (all locales).
- Markup supports data-i18n-context; the radio STATION TAG option uses
"radio tag|All", so it no longer flips gender when the list loads.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
250 lines
7.7 KiB
JavaScript
250 lines
7.7 KiB
JavaScript
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
|
|
|
// Shared with the AI tools, so no i18n import: these errors stay English here
|
|
// and the cyclones layer translates them where it shows them (translateKnown).
|
|
const msg = (text) => text;
|
|
|
|
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
|
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
|
|
const text = (value, max = 160) => {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!value.trim() ||
|
|
value.length > max ||
|
|
/[\u0000-\u001f<>]/.test(value)
|
|
)
|
|
throw malformed();
|
|
return value;
|
|
};
|
|
const number = (value, low, high) => {
|
|
if (value === null) return null;
|
|
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
|
|
return value;
|
|
};
|
|
const time = (value) => {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
|
!Number.isFinite(Date.parse(value)) ||
|
|
new Date(value).toISOString() !== value
|
|
)
|
|
throw malformed();
|
|
return value;
|
|
};
|
|
const advisory = (value) => {
|
|
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
|
|
throw malformed();
|
|
return value;
|
|
};
|
|
const position = (value) => {
|
|
if (!value || value.longitude === null || value.latitude === null)
|
|
throw malformed();
|
|
return {
|
|
longitude: number(value.longitude, -180, 180),
|
|
latitude: number(value.latitude, -90, 90),
|
|
};
|
|
};
|
|
|
|
function officialLink(value, outlook = false) {
|
|
if (value === null) return null;
|
|
const url = new URL(text(value, 256));
|
|
if (
|
|
url.origin !== 'https://www.nhc.noaa.gov' ||
|
|
url.username ||
|
|
url.password ||
|
|
url.hash
|
|
)
|
|
throw malformed();
|
|
if (
|
|
outlook
|
|
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
|
|
url.pathname + url.search,
|
|
)
|
|
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
|
|
)
|
|
throw malformed();
|
|
return url.href;
|
|
}
|
|
|
|
function geometry(value, kind, budget) {
|
|
if (value === null) return null;
|
|
const allowed =
|
|
kind === 'track'
|
|
? ['LineString', 'MultiLineString']
|
|
: ['Polygon', 'MultiPolygon'];
|
|
if (!allowed.includes(value?.type)) throw malformed();
|
|
const point = (pair) => {
|
|
if (
|
|
!Array.isArray(pair) ||
|
|
pair.length !== 2 ||
|
|
pair.some((v) => typeof v !== 'number')
|
|
)
|
|
throw malformed();
|
|
position({ longitude: pair[0], latitude: pair[1] });
|
|
if (++budget.coordinates > 25_000) throw malformed();
|
|
return [...pair];
|
|
};
|
|
const list = (items, read, min = 1, max = 128) => {
|
|
if (!Array.isArray(items) || items.length < min || items.length > max)
|
|
throw malformed();
|
|
return items.map(read);
|
|
};
|
|
const line = (pairs) => list(pairs, point, 2, 10_000);
|
|
const ring = (pairs) => {
|
|
const result = list(pairs, point, 4, 10_000);
|
|
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
|
|
throw malformed();
|
|
return result;
|
|
};
|
|
const polygon = (rings) => list(rings, ring);
|
|
const coordinates =
|
|
value.type === 'LineString'
|
|
? line(value.coordinates)
|
|
: value.type === 'MultiLineString'
|
|
? list(value.coordinates, line)
|
|
: value.type === 'Polygon'
|
|
? polygon(value.coordinates)
|
|
: list(value.coordinates, polygon);
|
|
return { type: value.type, coordinates };
|
|
}
|
|
|
|
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
|
|
export function validateCycloneSnapshot(value) {
|
|
if (
|
|
!value ||
|
|
value.schemaVersion !== 1 ||
|
|
typeof value.stale !== 'boolean' ||
|
|
typeof value.unavailable !== 'boolean' ||
|
|
!Array.isArray(value.storms) ||
|
|
value.storms.length > 32
|
|
)
|
|
throw malformed();
|
|
const seen = new Set(),
|
|
budget = { coordinates: 0, points: 0 };
|
|
const storms = value.storms.map((raw) => {
|
|
if (
|
|
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
|
|
seen.has(raw.id) ||
|
|
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
|
|
)
|
|
throw malformed();
|
|
seen.add(raw.id);
|
|
const advisoryNumber = advisory(raw.advisoryNumber);
|
|
const geometryAdvisoryNumber =
|
|
raw.geometryAdvisoryNumber === null
|
|
? null
|
|
: advisory(raw.geometryAdvisoryNumber);
|
|
if (
|
|
raw.geometryStatus === 'current' &&
|
|
geometryAdvisoryNumber !== advisoryNumber
|
|
)
|
|
throw malformed();
|
|
if (
|
|
!Array.isArray(raw.forecastPoints) ||
|
|
(budget.points += raw.forecastPoints.length) > 500
|
|
)
|
|
throw malformed();
|
|
const taus = new Set();
|
|
const forecastPoints = raw.forecastPoints.map((point) => {
|
|
const tauHours = number(point.tauHours, 0, 168);
|
|
if (tauHours === null || taus.has(tauHours)) throw malformed();
|
|
taus.add(tauHours);
|
|
if (++budget.coordinates > 25_000) throw malformed();
|
|
return {
|
|
position: position(point.position),
|
|
tauHours,
|
|
windKt: number(point.windKt, 0, 300),
|
|
gustKt: number(point.gustKt, 0, 350),
|
|
};
|
|
});
|
|
const track = geometry(raw.track, 'track', budget),
|
|
cone = geometry(raw.cone, 'cone', budget);
|
|
if (
|
|
raw.geometryStatus !== 'current' &&
|
|
(track || cone || forecastPoints.length)
|
|
)
|
|
throw malformed();
|
|
if (
|
|
raw.geometryStatus === 'current' &&
|
|
(!track || !cone || !forecastPoints.length)
|
|
)
|
|
throw malformed();
|
|
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
|
|
return {
|
|
id: raw.id,
|
|
name: text(raw.name, 80),
|
|
classification: text(raw.classification, 16),
|
|
basin: raw.basin,
|
|
position: position(raw.position),
|
|
positionAt: time(raw.positionAt),
|
|
advisoryNumber,
|
|
issuedAt: time(raw.issuedAt),
|
|
windKt: number(raw.windKt, 0, 300),
|
|
pressureHpa: number(raw.pressureHpa, 800, 1100),
|
|
movement: {
|
|
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
|
|
speedKt: number(raw.movement?.speedKt, 0, 200),
|
|
},
|
|
advisoryUrl: officialLink(raw.advisoryUrl),
|
|
outlookUrl: officialLink(raw.outlookUrl, true),
|
|
geometryStatus: raw.geometryStatus,
|
|
geometryAdvisoryNumber,
|
|
forecastPoints,
|
|
track,
|
|
cone,
|
|
};
|
|
});
|
|
if (value.unavailable && storms.length) throw malformed();
|
|
return {
|
|
schemaVersion: 1,
|
|
source: text(value.source),
|
|
attribution: text(value.attribution, 240),
|
|
coverage: text(value.coverage, 240),
|
|
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
|
|
stale: value.stale,
|
|
unavailable: value.unavailable,
|
|
reason: value.reason === null ? null : text(value.reason, 240),
|
|
storms,
|
|
};
|
|
}
|
|
|
|
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
|
|
export function createCycloneSource({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
timeoutMs = 15_000,
|
|
} = {}) {
|
|
return {
|
|
async getSnapshot({ signal } = {}) {
|
|
const controller = new AbortController();
|
|
const abort = () => controller.abort(signal.reason);
|
|
signal?.addEventListener('abort', abort, { once: true });
|
|
const timer = setTimeout(
|
|
() => controller.abort(new Error(msg('Cyclone request timed out'))),
|
|
timeoutMs,
|
|
);
|
|
try {
|
|
signal?.throwIfAborted();
|
|
const response = await fetchImpl('/api/cyclones', {
|
|
signal: controller.signal,
|
|
cache: 'no-store',
|
|
redirect: 'error',
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel();
|
|
throw new Error(`Cyclone HTTP ${response.status}`);
|
|
}
|
|
const result = await readResponseJsonCapped(
|
|
response,
|
|
CYCLONE_RESPONSE_LIMIT,
|
|
controller.signal,
|
|
);
|
|
controller.signal.throwIfAborted();
|
|
return validateCycloneSnapshot(result);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', abort);
|
|
}
|
|
},
|
|
};
|
|
}
|