Files
gods-eye-view/src/layers/cyclones/source.js
T
German KhokhlovandClaude Opus 5.5 858ce827e5 i18n(pt): apply review round 2
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]>
2026-10-28 17:57:51 +04:00

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);
}
},
};
}