refactor(app): share application components and request services

This commit is contained in:
Sameh Khamis committed 2026-09-13 22:41:11 -07:00
1 parent a0a0128a59
commit a67bc60a29
84 files changed
+2798 -1731

No files matched your search

+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import {
createAlprCamerasLayer,
createOverpassAlprSource,
@@ -7,7 +8,13 @@ import * as context from './contextStore.js';
import * as picking from './pickRegistry.js';
import * as groundFloor from './groundFloor.js';
export * from '../layers/alpr/index.js';
const slot = createSourceSlot(
createOverpassAlprSource(),
['fetch'],
'ALPR source',
);
export const configureAlprSource = slot.configure;
export default createAlprCamerasLayer({
source: createOverpassAlprSource(),
source: slot.source,
services: { render, context, picking, groundFloor },
});
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js';
import { createBikeshareLayer } from '../layers/bikeshare/index.js';
import * as render from '../renderGovernor.js';
@@ -5,8 +6,14 @@ import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as overlays from '../overlays/worldOverlay.js';
const sourceSlot = createSourceSlot(
createBikeshareSource(),
['getStations'],
'Bikeshare source',
);
export const configureBikeshareSource = sourceSlot.configure;
const layer = createBikeshareLayer({
source: createBikeshareSource(),
source: sourceSlot.source,
services: { render, sprites, picking, overlays },
});
export const createBikeshareSelectedOverlayEntry =
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js';
import { createCctvLayer } from '../layers/cctv/index.js';
import * as sprites from './spriteOrder.js';
@@ -11,8 +12,14 @@ import * as mesh from './meshFloorSampler.js';
import * as focus from './focusDeemphasis.js';
import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createCctvSource(),
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
'Cctv source',
);
export const configureCctvSource = sourceSlot.configure;
const layer = createCctvLayer({
source: createCctvSource(),
source: sourceSlot.source,
services: {
sprites,
activation,
+2 -2
View File
@@ -300,12 +300,12 @@ export function registerDynamicCredit(viewer, credit) {
* present in the "Data attribution" popover.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
*/
export function registerDataCredits(viewer) {
export function registerDataCredits(viewer, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
return;
}
for (const { html } of DATA_CREDITS) {
for (const { html } of credits) {
// showOnScreen=false → lives in the expandable "Data attribution" popover,
// not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -18,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0);
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import * as Cesium from 'cesium';
import {
createFirmsHeatmapLayer as createLayer,
@@ -20,6 +21,12 @@ const services = {
overlays,
focus,
};
const sourceSlot = createSourceSlot(
createFirmsSource(),
['getSnapshot'],
'Fire source',
);
export const configureFirmsSource = sourceSlot.configure;
const helpers = createFirmsHelpers({ services });
export const mapAnalystRecord = helpers.mapAnalystRecord;
export const fireCullPosition = helpers.fireCullPosition;
@@ -35,7 +42,7 @@ export function createFirmsHeatmapLayer(options) {
...options,
icon: options.icon ?? '▲',
source: options.source ?? 'NASA FIRMS',
feed: options.feed ?? createFirmsSource(),
feed: options.feed ?? sourceSlot.source,
services,
overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries,
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -377,7 +378,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity');
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
const html = expandApplicationHtml(await readFile(new URL('../../index.html', import.meta.url), 'utf8'));
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import {
createInstallationsLayer,
createInstallationSource,
@@ -8,8 +9,14 @@ import * as ground from './groundFloor.js';
import * as anchors from './fireAnchors.js';
import * as picking from './pickRegistry.js';
const sourceSlot = createSourceSlot(
createInstallationSource(),
['getMappedSites', 'searchNearby'],
'Installation source',
);
export const configureInstallationSource = sourceSlot.configure;
const layer = createInstallationsLayer({
source: createInstallationSource(),
source: sourceSlot.source,
services: { render, context, ground, anchors, picking },
});
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
+3
View File
@@ -9,3 +9,6 @@ export const registerMilitaryIcaos = registry.registerMilitaryIcaos;
export const isMilitaryIcao = registry.isMilitaryIcao;
export const refreshMilitaryRegistryIfStale =
registry.refreshMilitaryRegistryIfStale;
/** Configure classification from the same source used by the aircraft layer. */
export const configureMilitaryRegistrySource = registry.configureSource;
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createRadioSource } from '../layers/radio/source.js';
import { createRadioLayer } from '../layers/radio/index.js';
import * as ground from './groundFloor.js';
@@ -6,8 +7,14 @@ import * as overlays from '../overlays/worldOverlay.js';
import * as globe from '../celestialRing.js';
import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createRadioSource(),
['getDirectory', 'recordClick'],
'Radio source',
);
export const configureRadioSource = sourceSlot.configure;
const layer = createRadioLayer({
source: createRadioSource(),
source: sourceSlot.source,
services: { ground, picking, overlays, globe, render },
});
export const radioGlobeLabel = layer.radioGlobeLabel;
+3 -127
View File
@@ -1,131 +1,7 @@
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function safeHttpUrl(value) {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
} catch {
return null;
}
}
/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */
export function normalizeRegionalPlace(payload) {
const address = payload?.address || {};
const locality = cleanText(
address.city || address.town || address.village || address.municipality
|| address.hamlet || address.county,
90,
);
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ') || country || cleanText(payload?.display_name, 120);
if (!label) return null;
return {
label,
locality: locality || null,
region: region || null,
country: country || null,
countryCode: cleanText(address.country_code, 4).toUpperCase() || null,
};
}
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
const seen = new Set();
const articles = [];
for (const row of rows) {
const url = safeHttpUrl(row?.url || row?.url_mobile);
const title = cleanText(row?.title, 180);
if (!url || !title) continue;
const signature = `${title.toLowerCase()}|${new URL(url).hostname}`;
if (seen.has(signature)) continue;
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
articles.push({
title,
url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
});
if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break;
}
return articles;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation),
cloudCoverPct: numberOrNull(current.cloud_cover),
windKph: numberOrNull(current.wind_speed_10m),
windDirectionDeg: numberOrNull(current.wind_direction_10m),
visibilityM: numberOrNull(current.visibility),
weatherCode: numberOrNull(current.weather_code),
};
}
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
if ([45, 48].includes(value)) return 'FOG';
if (value >= 51 && value <= 57) return 'DRIZZLE';
if (value >= 61 && value <= 67) return 'RAIN';
if (value >= 71 && value <= 77) return 'SNOW';
if (value >= 80 && value <= 82) return 'RAIN SHOWERS';
if (value >= 85 && value <= 86) return 'SNOW SHOWERS';
if (value >= 95) return 'THUNDERSTORM';
return 'MIXED CONDITIONS';
}
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
return Infinity;
}
const phi1 = from.latitude * Math.PI / 180;
const phi2 = to.latitude * Math.PI / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) ** 2
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
import { applicationServices } from '../services/application.js';
export * from './regionalModel.js';
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
const params = new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) });
const response = await fetch(`/api/regional-brief?${params}`, { signal });
if (!response.ok) throw new Error(`Regional brief unavailable (${response.status})`);
return response.json();
return applicationServices.regional.getBrief(latitude, longitude, { signal });
}
+122
View File
@@ -0,0 +1,122 @@
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function safeHttpUrl(value) {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
} catch {
return null;
}
}
/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */
export function normalizeRegionalPlace(payload) {
const address = payload?.address || {};
const locality = cleanText(
address.city || address.town || address.village || address.municipality
|| address.hamlet || address.county,
90,
);
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ') || country || cleanText(payload?.display_name, 120);
if (!label) return null;
return {
label,
locality: locality || null,
region: region || null,
country: country || null,
countryCode: cleanText(address.country_code, 4).toUpperCase() || null,
};
}
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
const seen = new Set();
const articles = [];
for (const row of rows) {
const url = safeHttpUrl(row?.url || row?.url_mobile);
const title = cleanText(row?.title, 180);
if (!url || !title) continue;
const signature = `${title.toLowerCase()}|${new URL(url).hostname}`;
if (seen.has(signature)) continue;
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
articles.push({
title,
url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
});
if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break;
}
return articles;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation),
cloudCoverPct: numberOrNull(current.cloud_cover),
windKph: numberOrNull(current.wind_speed_10m),
windDirectionDeg: numberOrNull(current.wind_direction_10m),
visibilityM: numberOrNull(current.visibility),
weatherCode: numberOrNull(current.weather_code),
};
}
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
if ([45, 48].includes(value)) return 'FOG';
if (value >= 51 && value <= 57) return 'DRIZZLE';
if (value >= 61 && value <= 67) return 'RAIN';
if (value >= 71 && value <= 77) return 'SNOW';
if (value >= 80 && value <= 82) return 'RAIN SHOWERS';
if (value >= 85 && value <= 86) return 'SNOW SHOWERS';
if (value >= 95) return 'THUNDERSTORM';
return 'MIXED CONDITIONS';
}
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
return Infinity;
}
const phi1 = from.latitude * Math.PI / 180;
const phi2 = to.latitude * Math.PI / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) ** 2
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import {
createRocketLaunchesLayer,
createLaunchSource,
@@ -7,8 +8,14 @@ import * as geometry from '../celestialRing.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createLaunchSource(),
['getLaunches', 'getActiveTle'],
'Launch source',
);
export const configureLaunchSource = sourceSlot.configure;
const layer = createRocketLaunchesLayer({
source: createLaunchSource(),
source: sourceSlot.source,
services: { satellites, geometry, overlays, render },
});
export const satelliteParamsForSpaceMissions =
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import {
createSatellitesLayer,
createSatelliteSource,
@@ -10,8 +11,14 @@ import * as context from './contextStore.js';
import * as render from '../renderGovernor.js';
import * as layerState from './layerState.js';
const sourceSlot = createSourceSlot(
createSatelliteSource(),
['readGroup'],
'Satellite source',
);
export const configureSatelliteSource = sourceSlot.configure;
const layer = createSatellitesLayer({
source: createSatelliteSource(),
source: sourceSlot.source,
services: { picking, focus, readout, overlays, context, render, layerState },
});
export const satelliteVisualsVisible = layer.satelliteVisualsVisible;
+3 -5
View File
@@ -1,3 +1,4 @@
import { applicationServices } from '../services/application.js';
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
//
@@ -101,11 +102,8 @@ export function cachedRealEllipsoidalGround(lat, lon) {
async function fetchChunk(chunk) {
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
// and Task 2's implementation).
const pointsParam = chunk.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`).join(';');
const url = `/api/terrain/heights?points=${encodeURIComponent(pointsParam)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`terrain heights proxy HTTP ${res.status}`);
const body = await res.json();
const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) });
const body = { results };
if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)');
if (body.results.length !== chunk.length) {
throw new Error(
+12 -1
View File
@@ -1,10 +1,21 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createTrafficSource } from '../layers/traffic/source.js';
import { createTrafficLayer } from '../layers/traffic/index.js';
import * as credits from './dataCredits.js';
import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createTrafficSource(),
['requestRoads', 'getStatus', 'fetchFlowForBounds'],
'Traffic source',
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache: () => {},
},
);
export const configureTrafficSource = sourceSlot.configure;
const layer = createTrafficLayer({
source: createTrafficSource(),
source: sourceSlot.source,
services: { credits, render },
});
export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics;