mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge current upstream and resolve PR 581 conflicts
b4067f9 moved the OpenSky states/all fetch into fetchOpenSkyStates, with a
10-second limit per attempt and one retry. Keep that and drop this branch's
20-second exchange deadline; the 8 MiB body cap now reads inside the helper
under each attempt's signal. The CHANGELOG bullet moves under [Unreleased],
and CURRENT-STATE describes the per-attempt OpenSky deadline.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
44a2083d7b
178 files changed
+19714
-1457
No files matched your search
@@ -0,0 +1,24 @@
|
||||
/** Event packs the app serves under `/events/<id>/`, read once per id. */
|
||||
|
||||
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function createEventPackSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async getEvent(id, { signal } = {}) {
|
||||
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
|
||||
if (!cache.has(id)) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/events/${id}/event.json`, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Event pack ${id} unavailable (${response.status})`);
|
||||
cache.set(id, await response.json());
|
||||
}
|
||||
return cache.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** Parse GBFS station documents, tolerating field-name variations across providers. */
|
||||
|
||||
/**
|
||||
* Coerce a GBFS boolean field to a native boolean.
|
||||
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
|
||||
* @param {*} value - Raw field value from GBFS JSON.
|
||||
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function normalizeGbfsBool(value, fallback = true) {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return num !== 0;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (text === 'true' || text === 'yes') return true;
|
||||
if (text === 'false' || text === 'no') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as a non-negative integer, returning fallback on failure.
|
||||
* @param {*} value - Raw numeric value.
|
||||
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function toNonNegativeInteger(value, fallback = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the stations array from a GBFS JSON payload.
|
||||
* Handles multiple response shapes: { data: { stations: [...] } },
|
||||
* { data: [...] }, and nested objects with a stations sub-key.
|
||||
* @param {Object} payload - Parsed GBFS JSON response.
|
||||
* @returns {Object[]} Array of raw station objects (may be empty).
|
||||
*/
|
||||
export function extractStationsArray(payload) {
|
||||
const data = payload?.data;
|
||||
if (Array.isArray(data?.stations)) return data.stations;
|
||||
if (Array.isArray(data)) return data;
|
||||
// Some feeds nest stations under an additional key (e.g. locale wrappers)
|
||||
if (data && typeof data === 'object') {
|
||||
for (const value of Object.values(data)) {
|
||||
if (Array.isArray(value?.stations)) return value.stations;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
|
||||
* invalid coordinates are silently skipped.
|
||||
* @param {Object} payload - Parsed station_information.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to station info objects.
|
||||
*/
|
||||
export function parseStationInformation(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS 2.x (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
});
|
||||
}
|
||||
|
||||
return stationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_status payload into a Map of real-time availability.
|
||||
* @param {Object} payload - Parsed station_status.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to status objects containing
|
||||
* bikesAvailable, docksAvailable, and operational flags.
|
||||
*/
|
||||
export function parseStationStatus(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const statusMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
if (!stationId) continue;
|
||||
|
||||
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
|
||||
const docks = toNonNegativeInteger(raw?.num_docks_available);
|
||||
statusMap.set(stationId, {
|
||||
stationId,
|
||||
bikesAvailable: bikes,
|
||||
docksAvailable: docks,
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
lastReported: toNonNegativeInteger(raw?.last_reported),
|
||||
});
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Bundled OpenStreetMap infrastructure layers (datacenters and dams): their
|
||||
* data files, line-delimited GeoJSON parsing and analyst records.
|
||||
*/
|
||||
|
||||
// Resolved by Vite in builds and relative to this module in other consumers,
|
||||
// when read, so importing this module never needs a module URL.
|
||||
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
|
||||
get 'local-datacenters'() {
|
||||
return new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
get 'local-dams'() {
|
||||
return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
|
||||
.href;
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse GeoJSON Lines: one Feature per non-empty line. */
|
||||
export function parseGeojsonLines(text) {
|
||||
return String(text)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
/** The singular label for a local infrastructure layer. */
|
||||
export function layerTitle(layerId) {
|
||||
if (layerId === 'local-datacenters') return 'Datacenter';
|
||||
if (layerId === 'local-dams') return 'Dam';
|
||||
return 'Feature';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one local infrastructure feature to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. Layer-specific fields that do not
|
||||
* apply (river/output on datacenters, capacity on dams) stay null so a
|
||||
* shared compact payload can copy them without inventing values.
|
||||
* Names are unclamped — overlay cards shorten for paint; queries need the
|
||||
* full source string ("Usina Hidrelétrica de Itaipu").
|
||||
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
|
||||
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
|
||||
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
|
||||
* operator: string|null, capacity: string|null, river: string|null,
|
||||
* output: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw, layerId = '') {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t && t !== 'undefined' && t !== 'null' ? t : null;
|
||||
};
|
||||
const props =
|
||||
raw?.properties &&
|
||||
typeof raw.properties === 'object' &&
|
||||
!Array.isArray(raw.properties)
|
||||
? raw.properties
|
||||
: {};
|
||||
const tags =
|
||||
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
|
||||
? props.tags
|
||||
: {};
|
||||
const name =
|
||||
text(props.name) ||
|
||||
text(tags.name) ||
|
||||
text(tags['name:en']) ||
|
||||
text(tags.official_name) ||
|
||||
null;
|
||||
const operator =
|
||||
text(tags.operator) ||
|
||||
text(props.operator) ||
|
||||
text(tags['operator:short']) ||
|
||||
null;
|
||||
const capacity =
|
||||
layerId === 'local-datacenters'
|
||||
? text(tags['capacity:it_load']) ||
|
||||
text(tags.it_load) ||
|
||||
text(tags.capacity) ||
|
||||
text(props.capacity)
|
||||
: null;
|
||||
const river =
|
||||
layerId === 'local-dams'
|
||||
? text(tags.associated_river) ||
|
||||
text(props.associated_river) ||
|
||||
text(tags.river) ||
|
||||
text(props.river) ||
|
||||
text(tags['river:name'])
|
||||
: null;
|
||||
const output =
|
||||
layerId === 'local-dams'
|
||||
? text(props.output) || text(tags['plant:output:electricity'])
|
||||
: null;
|
||||
return {
|
||||
id: name || text(raw?.id) || layerTitle(layerId),
|
||||
name,
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
operator,
|
||||
capacity,
|
||||
river,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
/** A representative point for a feature: its point, or its outline's average vertex. */
|
||||
export function featurePoint(feature) {
|
||||
const geometry = feature?.geometry;
|
||||
const outline =
|
||||
geometry?.type === 'Point'
|
||||
? [geometry.coordinates]
|
||||
: geometry?.type === 'LineString'
|
||||
? geometry.coordinates
|
||||
: geometry?.type === 'Polygon'
|
||||
? geometry.coordinates?.[0]
|
||||
: geometry?.type === 'MultiPolygon'
|
||||
? geometry.coordinates?.[0]?.[0]
|
||||
: null;
|
||||
const points = (outline || []).filter(
|
||||
(point) => Number.isFinite(point?.[0]) && Number.isFinite(point?.[1]),
|
||||
);
|
||||
if (!points.length) return null;
|
||||
return {
|
||||
lon: points.reduce((sum, point) => sum + point[0], 0) / points.length,
|
||||
lat: points.reduce((sum, point) => sum + point[1], 0) / points.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the bundled layers' analyst records through a supplied transport. */
|
||||
export function createInfrastructureSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async getRecords(layerId, { signal } = {}) {
|
||||
const url = INFRASTRUCTURE_DATA_URLS[layerId];
|
||||
if (!url) throw new TypeError(`Unknown infrastructure layer: ${layerId}`);
|
||||
if (!cache.has(layerId)) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const records = parseGeojsonLines(await response.text()).flatMap(
|
||||
(feature) => {
|
||||
const point = featurePoint(feature);
|
||||
return point
|
||||
? [
|
||||
mapAnalystRecord(
|
||||
{
|
||||
id: feature.properties?.osm_id ?? feature.id,
|
||||
...point,
|
||||
properties: feature.properties,
|
||||
},
|
||||
layerId,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
cache.set(layerId, records);
|
||||
}
|
||||
return cache.get(layerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
||||
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
|
||||
*/
|
||||
export function parseTleText(text) {
|
||||
const lines = String(text)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const result = [];
|
||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
result.push({ name, line1, line2 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The NORAD catalog number from TLE line 1, or null. */
|
||||
export function tleCatalogNumber(line1) {
|
||||
const number = Number.parseInt(String(line1).slice(2, 7), 10);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
}
|
||||
Reference in new issue
Block a user