feat: v0.11.0 — map interactions per PLAN.md: expanded airport popup (approx local time + deterministic generated weather), animated plane markers from /api/state polls with status popups, active contract route lines (pending dashed vs in-flight solid); pure libs lib/weather.ts, lib/localTime.ts, lib/mapData.ts

This commit is contained in:
2026-09-18 07:40:39 +00:00
parent 4c06ed26ff
commit 0208673da7
11 changed files with 874 additions and 14 deletions
+101
View File
@@ -0,0 +1,101 @@
import type { StateSnapshot } from './game/state';
import { formatFuel } from './format';
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature
// per plane, one LineString per accepted/in-flight contract. Kept out of
// components/Map.tsx so the classification logic is unit-testable without a DOM.
export interface PlaneFeatureProps {
id: string;
modelLabel: string;
status: 'available' | 'flying';
fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded
flying: boolean;
}
export interface RouteFeatureProps {
contractId: string;
kind: 'pending' | 'inflight';
}
export interface MapPointFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: PlaneFeatureProps;
}
export interface MapLineFeature {
type: 'Feature';
geometry: { type: 'LineString'; coordinates: [number, number][] };
properties: RouteFeatureProps;
}
export interface FeatureCollection<T> {
type: 'FeatureCollection';
features: T[];
}
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
if (!state) return EMPTY_POINTS;
const contractById = new Map(state.contracts.map((c) => [c.id, c]));
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
const features: MapPointFeature[] = state.planes.map((p) => {
const flying = p.status === 'flying';
const contract = p.contractId ? contractById.get(p.contractId) : undefined;
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
id: p.id,
modelLabel: labelByModel.get(p.model) ?? p.model,
status: p.status,
fuelText: formatFuel(p.fuel, p.maxFuel),
routeText: flying && contract ? `${contract.originName} → ${contract.destName}` : '',
flying,
},
};
});
return { type: 'FeatureCollection', features };
}
// Airport coordinate lookup for route endpoints (from the /api/airports GeoJSON).
export interface AirportCoord {
lat: number;
lon: number;
}
// One LineString per contract that is accepted but not yet dispatched ('pending')
// or already flying ('inflight' — planeId set and that plane's status is 'flying').
// Open/completed/expired contracts draw nothing.
export function routesGeoJson(
state: StateSnapshot | null,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
if (!state) return EMPTY_LINES;
const planeById = new Map(state.planes.map((p) => [p.id, p]));
const features: MapLineFeature[] = [];
for (const c of state.contracts) {
if (c.status !== 'accepted') continue;
const from = airportsByIcao.get(c.originIcao);
const to = airportsByIcao.get(c.destIcao);
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
const plane = c.planeId ? planeById.get(c.planeId) : undefined;
features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[from.lon, from.lat],
[to.lon, to.lat],
],
},
properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' },
});
}
return { type: 'FeatureCollection', features };
}