diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dd772ed..d16ec8c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -235,6 +235,32 @@ Two changes make it work, and both are cheap now: SF gets one focus region covering the whole city and behaves exactly as it does now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs. +### 5.1 The heightfield is built off the main thread + +Both numbers above are seconds during which nothing rendered and nothing +responded, because the build ran on the thread that paints. It runs in a Worker +now (`src/engine/terrain.worker.ts`), which is why `createScene` is async and +takes a `Stage` rather than a canvas. + +Three consequences worth knowing before touching it: + +- **The city pack has to stay structured-cloneable.** It is posted to the + worker as data. A `City` that acquires a method, a class instance or a + closure stops being sendable, and the fix is to remove it rather than to + JSON round-trip around it — a city pack containing code is the thing §2 + exists to prevent. +- **The sampled accessors stay synchronous.** `elevationAt`, `groundAt` and + `isLand` are called in tight loops by `terrain.ts`, `blocks.ts` and + `minimap.ts`; the asynchrony is confined to *becoming ready*, and sampling + before then is a documented error rather than a silent zero. +- **There is a main-thread fallback and it is not optional.** An environment + without Workers — a `file://` open, a locked-down browser — still has to + build and render, because "clone it and it works" has no exception clause. + +The same reasoning put Spaces behind an `await import()`: the office is a large +slice of the bundle and most visitors never open it, so it is fetched when +somebody reaches for the door rather than by everybody at boot. + --- ## 6. How Workie feeds it diff --git a/NOTICE b/NOTICE index c151163..1eb4a2d 100644 --- a/NOTICE +++ b/NOTICE @@ -96,3 +96,34 @@ The flight sources shipped in this repository are a simulator (original work) and clients for open community ADS-B feeds. No commercial aviation data provider's data is included or redistributed, and no client for a provider whose terms prohibit such use is present. + +No aircraft position is committed to this repository; the community feeds are +fetched at runtime by a deployment that has been configured to use one. Where +adsb.lol answers, the API attaches the credit line that feed asks for and the +browser displays it in the shortcuts card alongside the weather credits below. + + +WEATHER DATA +------------ + +No weather observation is committed to this repository either. The default +build observes nothing at all and models the sky locally, and the shipped +sources are all opt-in. + +Two of them carry an attribution obligation, and it is met at runtime rather +than here, because which one applies is a property of the deployment and not of +the source: + + - MET Norway (met.no) — CC BY 4.0 + - Open-Meteo.com — CC BY 4.0 + +The API emits the credit line each of them asks for in the `attribution` array +on the weather body, and the browser displays whatever it is sent, for as long +as that source is what is on screen. See `server/src/weather/` for the strings +and `renderCredits` in `src/main.ts` for where they land. A deployment that +turns one of these on and strips the credit is the party in breach, not this +repository — but the plumbing to comply ships working, on purpose. + +The US National Weather Service (api.weather.gov) is a United States government +work in the public domain and carries no such obligation, which is why it sends +no attribution array; claiming one would be inventing a licence term. diff --git a/README.md b/README.md index 403de44..e0e3a76 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Three tiers, resolved once at boot by `src/access.ts`: | the map, the plan view, the named chapters | ✅ | ✅ | ✅ | | the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth | | live markers and live traffic | — | ✅ | ✅ | -| the time scrubber and debug readouts | — | — | ✅ | +| the godmode panel (`G`) — clock, weather override, counters | — | — | ✅ | **These are drawing decisions, not a security boundary**, and `src/access.ts` says so at length. Live data and office presence are withheld by the *API*, from @@ -56,18 +56,28 @@ npm run dev ```ts import { createScene } from "@lumbridge/tera/engine/scene.ts"; +import { createStage } from "@lumbridge/tera/engine/stage.ts"; import SAN_FRANCISCO from "@lumbridge/tera/cities/sf.ts"; -const scene = createScene(canvas, { +// One stage per canvas, for the life of the page. Cities are put on it and +// taken off again; a renderer per city leaks its shadow map on every switch. +const stage = createStage(canvas); + +const scene = await createScene(stage, { city: SAN_FRANCISCO, markerPalette: { hiring: 0x4ade80, closed: 0xef4444 }, }); -scene.setMarkers([ +scene?.setMarkers([ { id: "1", lat: 37.7765, lng: -122.4241, label: "Somewhere", colorKey: "hiring" }, ]); ``` +`createScene` is async because the heightfield is built in a Worker — half a +million samples, about 730 ms on the Bay Area, and not on the main thread. It +resolves to `null` if the build was abandoned through `options.signal`, which is +what makes switching city mid-build cheap. + The engine renders `Marker[]` and looks colours up by `colorKey` in a palette you supply. It does not know what your markers *mean* — that mapping lives in your adapter. This is what lets one renderer serve a private map coloured by @@ -104,10 +114,18 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with. src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap src/cities/ data packs — pure geography, no code src/adapters/ where outside data plugs in +src/tools/ instruments — god-only, dynamically imported, never statically ``` `engine` never imports `cities`; neither imports `adapters`. +Nothing under `src/tools/` may be reached by a static import from the app. It is +loaded by one `await import()` behind `access.can.debug`, so a visitor who is +not an admin does not download the code at all — which is the strongest +available reading of "nothing here runs for a non-god visitor": not a hidden +panel, not a disabled panel, no panel. `src/tools/index.ts` states the rule and +what silently undoes it. + ## Licence Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/index.html b/index.html index 007a12d..af198e2 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,16 @@ - + + +

+ +
+ diff --git a/server/README.md b/server/README.md index 495927c..0a9c74f 100644 --- a/server/README.md +++ b/server/README.md @@ -23,23 +23,72 @@ without breaking the typecheck, which is the wrong order to find out. ## Routes -| route | body | cached | -| --- | --- | --- | -| `GET /api/v1/health` | `HealthBody` | never | -| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` | -| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` | -| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` | -| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only | +| route | query | body | cached | +| --- | --- | --- | --- | +| `GET /api/v1/health` | — | `HealthBody` | never | +| `GET /api/v1/flights` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `FlightsBody` | public, `TERA_FLIGHTS_TTL` | +| `GET /api/v1/weather` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `WeatherBody` | public, `TERA_WEATHER_TTL` | +| `GET /api/v1/markers` | — | `MarkersBody` | **private; 401 unless signed in** — public empty body when `TERA_MARKERS_SOURCE=none` | +| `GET /api/v1/offices/:id` | — | `OfficeDoc` | public offices only | Every body is declared once, in `src/server/wire.ts` in the **root** package — type-only, so it compiles to nothing and both the browser build and this service import the same declarations without either becoming a dependency of the other. +Both location parameters are optional and omitting them answers for the default +region, which is the first entry in `TERA_REGIONS`. Giving both `city` and a +coordinate is a 400, as is a coordinate this deployment has nothing to say +about; see **Regions** below for why that is a refusal and not a lookup. +`radiusNm` is accepted on `/flights` and deliberately ignored — `routes/flights.ts` +explains what a caller-chosen cache key would dissolve. + `Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every reply before any route runs, and a route opts in explicitly. A request that arrived with an `Authorization` header or a cookie never gets a public policy, whatever the route asked for. +## Regions + +**A caller's coordinate is never forwarded upstream. It only selects among the +points the operator configured.** Weather and flights answer for a *resolved +region*, and a request for somewhere this box does not serve is a 400 naming +what it does. + +That is an allowlist rather than a lookup because the obvious version — take +`?lat=&lng=` and hand it to NWS — turns an unauthenticated endpoint into a free +geocoding proxy for the planet: an amplifier pointed at somebody else's +public-good API, from an address they will blame, with the operator's own +contact string on every request. It is also what bounds everything downstream, +since the upstream key space *is* the region list: the per-region caches, the +NWS station cache and the adsb.lol poll budget are all bounded by the +environment file and cannot be grown by anybody sending requests. +(`src/regions.ts` has the full reasoning, including why snapping to a coarse +grid was rejected.) + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_REGIONS` | *(empty)* | `id:lat,lng[:radiusKm]`, separated by `;` or newlines. The list, in the operator's order; the first is the default. | + +```ini +TERA_REGIONS=sf:37.7749,-122.4194;socal:33.82,-118.05:150 +``` + +Ids are the same ones the browser's city packs use. `radiusKm` defaults to 120, +which covers both shipped boards with room to spare and leaves them disjoint. A +malformed entry is dropped with a line in `degraded`, and a spec in which +nothing parses falls back to the shipped pair — a typo is a demotion, never a +refusal to boot. + +Left empty, the box serves the two cities the map ships with. An operator who +pointed `TERA_ORIGIN_LAT/_LNG` somewhere else additionally gets that point as a +region named `origin`, first in the list and therefore the default, so a bare +`GET /api/v1/weather` on their box answers exactly as it did before regions +existed. + +`GET /api/v1/health` publishes the resolved list as `regions`, in the same +order, so a client can pick its default the way the server does instead of +guessing a `?city=` and getting a 400 it cannot explain. + ## Configuration Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A @@ -53,7 +102,7 @@ missing weather contact string; this is the correction. (CONTRACT.md §5.1.) | `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. | | `TERA_PORT` | `8431` | | | `TERA_LOG_LEVEL` | `info` | | -| `TERA_ORIGIN_LAT` / `_LNG` | SF | The city this box serves. Weather point and flight-plan centre. | +| `TERA_ORIGIN_LAT` / `_LNG` | SF | Default region only, and superseded entirely by `TERA_REGIONS`. Nothing per-request reads it. | | `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. | | `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. | @@ -83,9 +132,9 @@ is a supported steady state, not an error path. | --- | --- | --- | | `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. | | `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. | -| `TERA_ADSB_RADIUS_NM` | `40` | | +| `TERA_ADSB_RADIUS_NM` | `40` | Clamped to 1–250 nm, which is what both feeds accept, with a `degraded` line. | | `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. | -| `TERA_FLIGHTS_TTL` | `300` | Clamped to 15 s for live sources. | +| `TERA_FLIGHTS_TTL` | `300` | For live sources, clamped **into 5–15 s**. | | `TERA_FLIGHTS_SEED` | `4711` | | The simulated source is served as a **route plan**, not as positions: the routes, @@ -93,6 +142,17 @@ a fixed phase origin and a seed, which every browser evaluates in closed form against wall-clock time. One cacheable request replaces a poll per second, and two people on different machines see the same aircraft in the same places. +The **floor** under the live TTL is the load-bearing half of that clamp, and it +is why the cell above reads as a range. adsb.lol asks for no more than one +request per second and airplanes.live publishes the same ceiling; both are +volunteer-fed. `TERA_FLIGHTS_TTL=0` used to mean one upstream request per +inbound request — the exact flood the limit exists to stop, delivered by a +setting that reads like "as fresh as possible". With the floor the worst case is +arithmetic rather than a guess: **regions ÷ 5 requests per second** with every +region under continuous load, which for the two shipped here is 0.4/s. An +operator configuring more than five regions and keeping them all warm is the +case to watch. + There is no FlightRadar24 client and there will not be one — their terms forbid scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box @@ -112,6 +172,29 @@ The API serves a file. It holds no database and no credential, and private per-user markers are never proxied through it — an authenticated browser calls Workie directly with its own token, so a private row never enters this process. +**Where a marker feed is configured, it takes a session.** This is the one route +the `member` tier is about, and until it refused somebody the tier was a word in +a type union that no server behaviour corresponded to. An anonymous caller gets +401 with `WWW-Authenticate: Bearer`; a member gets the snapshot with no public +cache policy on it, because a body that took a credential to obtain must not sit +in a shared cache waiting for the next caller. There is deliberately no +`TERA_MARKERS_PUBLIC` escape hatch. (401 rather than the 404 an office answers +with: nothing here is enumerable — one feed, one path, and `/api/v1/health` +already publishes `sources.markers` — so the caller is told the useful thing, +which is "sign in and ask again".) + +Two consequences worth knowing before you configure it: + +- **A box with no feed still answers 200 and an empty list.** `source: none` is + the zero-config default, there is nothing there to protect, and making a + stranger sign in to be told "no markers" would fail the acceptance test at the + top of this file. +- **`TERA_MARKERS_SOURCE=file` with `TERA_AUTH_MODE=none` is unreachable by + everyone**, because nobody on such a box is ever authenticated. That is the + fail-closed direction and it is the one private offices already take; the + config pushes a line into `degraded` saying so, rather than letting it be + discovered from an empty map. + **Every row must declare where its coordinate came from, and the gate refuses anything not on the allowlist.** Serving a snapshot of geocoded coordinates is Public Use of a Derivative Database; if those coordinates came from Nominatim, diff --git a/server/src/config.ts b/server/src/config.ts index 1f4646e..a84f769 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -19,6 +19,7 @@ import { readFileSync } from "node:fs"; import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; +import { loadRegions, type RegionSet } from "./regions.ts"; import type { AuthMode, FlightsSourceId, @@ -114,8 +115,17 @@ export interface Config { port: number; logLevel: string; version: string; - /** The city this box is serving, in degrees. Used by weather and by flights. */ + /** + * The default place this box serves, in degrees. + * + * Kept because it is what a self-hoster already wrote in their env file, and + * because `regions[0]` is derived from it. Nothing per-request reads it any + * more: weather and flights answer for a *resolved region*, since one origin + * cannot describe two cities six hundred kilometres apart. See `regions.ts`. + */ origin: { lat: number; lng: number }; + /** Every place this box will answer for. The first one is the default. */ + regions: RegionSet; /** Allowed CORS origins. Empty means same-origin only, which is the default. */ corsOrigins: string[]; /** `max-age` for routes that opt in to public caching. */ @@ -136,18 +146,29 @@ export function loadConfig(env: Env = process.env): Config { const weather = loadWeather(env, degraded); const flights = loadFlights(env, degraded); - const markers = loadMarkers(env, degraded); const auth = loadAuth(env, degraded); + // After auth, because a marker feed with nobody able to sign in is worth a + // sentence and the sentence is only true once `mode` has finished demoting. + const markers = loadMarkers(env, auth.mode, degraded); + + const origin = { + lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded), + lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded), + }; return { host: str(env, "TERA_HOST", "127.0.0.1"), port: num(env, "TERA_PORT", 8431, degraded), logLevel: str(env, "TERA_LOG_LEVEL", "info"), version: readVersion(), - origin: { - lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded), - lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded), - }, + origin, + regions: loadRegions({ + spec: str(env, "TERA_REGIONS", ""), + origin, + originConfigured: + str(env, "TERA_ORIGIN_LAT", "") !== "" || str(env, "TERA_ORIGIN_LNG", "") !== "", + degraded, + }), corsOrigins: list(env, "TERA_CORS_ORIGIN"), publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded), weather, @@ -236,7 +257,7 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig { return { source, endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"), - radiusNm: num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), + radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded), dump1090Path, epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded), @@ -244,12 +265,31 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig { }; } +/** + * The largest circle the hosted feeds will answer for: adsb.lol and + * airplanes.live both cap `/v2/point/:lat/:lng/:radius` at 250 nautical miles + * and reject anything larger outright. Clamping here rather than letting the + * request fail keeps the failure legible — an operator who typed 2500 gets a map + * with aircraft on it and a sentence in `degraded`, not a silently empty sky. + */ +const MAX_ADSB_RADIUS_NM = 250; + +function radius(asked: number, degraded: string[]): number { + if (asked >= 1 && asked <= MAX_ADSB_RADIUS_NM) return asked; + const clamped = Math.min(MAX_ADSB_RADIUS_NM, Math.max(1, asked)); + degraded.push( + `TERA_ADSB_RADIUS_NM=${asked} is outside the 1–${MAX_ADSB_RADIUS_NM} nautical ` + + `miles the hosted feeds answer for; using ${clamped}.`, + ); + return clamped; +} + const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"]; /** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */ export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"]; -function loadMarkers(env: Env, degraded: string[]): MarkersConfig { +function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersConfig { const asked = str(env, "TERA_MARKERS_SOURCE", "none"); let source = oneOf(asked, MARKER_SOURCES); if (source === null) { @@ -268,6 +308,19 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig { source = "none"; } + // Not a demotion — the feed stays configured and the route keeps refusing + // correctly — but an operator who mounted a marker file on a box where nobody + // can sign in has built something no browser will ever see, and one sentence + // now is cheaper than an afternoon. `routes/markers.ts` has the reasoning for + // why the feed is members-only with no public escape hatch. + if (source === "file" && authMode === "none") { + degraded.push( + "TERA_MARKERS_SOURCE=file needs an authentication mode: the marker feed is " + + "refused to anonymous callers, and with TERA_AUTH_MODE=none nobody is ever " + + "anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE.", + ); + } + const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST"); return { source, diff --git a/server/src/flights/adsb.ts b/server/src/flights/adsb.ts index 0f0102e..8061d84 100644 --- a/server/src/flights/adsb.ts +++ b/server/src/flights/adsb.ts @@ -38,15 +38,36 @@ export interface FlightsSnapshot { observedAt: number; } +/** Just enough of the service's logger to say a feed came back oversized. */ +export interface AdsbLog { + warn(msg: string): void; +} + +/** + * One circle of sky, from a hosted feed. + * + * `center` is a **region centre from `regions.ts`**, never a coordinate off the + * wire. That is the whole reason the routes validate before they get here: this + * function will happily fetch anywhere, and the thing standing between it and + * being an open proxy pointed at a community feed is that its callers can only + * hand it points an operator configured. The radius is clamped to the 250 nm + * both feeds accept, in `config.ts`. + * + * How often this is allowed to be called, and the arithmetic that keeps it + * inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`. + */ export async function fetchAdsb( endpoint: string, center: { lat: number; lng: number }, radiusNm: number, + log?: AdsbLog, ): Promise { const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; const body = await getJson(url); if (body === null) return null; - return normalise(body); + return normalise(body, (dropped) => + log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`), + ); } /** @@ -57,19 +78,75 @@ export async function fetchAdsb( * returns `null` and lets the caller keep the previous snapshot instead of * emptying the sky for one tick. */ -export async function readDump1090(path: string): Promise { +export async function readDump1090(path: string, log?: AdsbLog): Promise { try { const text = await readFile(path, "utf8"); - return normalise(JSON.parse(text) as AircraftEnvelope); + return normalise(JSON.parse(text) as AircraftEnvelope, (dropped) => + log?.warn( + `flights:dump1090: ${path} held ${dropped + MAX_ROWS} aircraft; ` + + `kept the first ${MAX_ROWS}`, + ), + ); } catch { return null; } } -function normalise(body: AircraftEnvelope): FlightsSnapshot { - const rows = body.ac ?? body.aircraft ?? []; +/** + * How many aircraft one snapshot may carry. + * + * Not a limit anybody should ever meet: a 250 nm circle over the busiest + * airspace on earth is a few thousand contacts, and the board draws a dart for + * each. It is here because the row count is set by somebody else's server and + * the parsed array is held in this process and served, cached, to every + * anonymous caller for the length of the TTL — a feed answering with 200,000 + * rows was measured at a 14.9 MB public body. The excess is dropped and + * counted rather than silently trimmed, so an operator whose sky is suddenly + * capped finds out from the log rather than from a map that looks thin. + */ +const MAX_ROWS = 5000; + +/** + * One envelope, turned into a snapshot — or `null` if it is not an envelope. + * + * ### Checked, not assumed + * + * This was `body.ac ?? body.aircraft ?? []` followed by a `for…of`, so a + * well-formed JSON 200 with a number in `ac` threw "rows is not iterable" out + * of `fetchAdsb` — past `upstream.ts`, which then neither stamped its clock nor + * caught it, and out to the route as a 500. `markers/gate.ts` has always done + * this for its own input; the hosted-feed path was the one that trusted the + * wire. + * + * ### Why an unreadable body is `null` and not an empty sky + * + * `?? []` would still be here and would still not throw, and it would be the + * wrong answer. `flights/index.ts` reads `null` as "fall back to the plan" and + * reads a snapshot — *including an empty one* — as observed truth, which is + * correct: three in the morning over a small city really is an empty sky. A + * garbled body coerced to zero aircraft is therefore served as `mode: "live"` + * with nothing in it, and the operator who has just turned on ADS-B gets a + * blank map that claims to be real. That is precisely the outcome the top of + * this file says the fallback exists to prevent. + * + * So the test is on the *array*, not on its length: a body carrying `ac: []` + * is an empty circle and is live; a body carrying neither `ac` nor `aircraft` + * as an array is not an answer at all. + */ +function normalise( + body: AircraftEnvelope, + onDrop?: (dropped: number) => void, +): FlightsSnapshot | null { + const raw = body.ac ?? body.aircraft; + if (!Array.isArray(raw)) return null; + const rows = raw.length > MAX_ROWS ? raw.slice(0, MAX_ROWS) : raw; + if (raw.length > rows.length) onDrop?.(raw.length - rows.length); + const aircraft: WireAircraft[] = []; for (const a of rows) { + // A row that is not an object at all reaches this from the same wire that + // sent a number where the array was. + if (a === null || typeof a !== "object") continue; if (typeof a.lat !== "number" || typeof a.lon !== "number") continue; const callsign = a.flight?.trim(); const id = a.hex ?? callsign; diff --git a/server/src/flights/index.ts b/server/src/flights/index.ts index 2f68c4f..bae29eb 100644 --- a/server/src/flights/index.ts +++ b/server/src/flights/index.ts @@ -1,5 +1,5 @@ /** - * Which sky this box serves. + * Which sky this box serves, and for which city. * * The simulated source answers from a plan and never touches the network, so it * is free and it is the default. The two real sources are polled on a timer and @@ -7,15 +7,48 @@ * answering serves its last snapshot, and a feed that has never answered falls * back to the plan rather than to an empty sky. An operator who turned on ADS-B * and got a blank map would reasonably conclude the renderer was broken. + * + * Everything here is **per region**. `TERA_ADSB_RADIUS_NM` around one origin + * could only ever describe one city, and the Bay Area wants SFO, OAK and SJC + * while the Southland wants LAX, BUR, LGB and SNA — six hundred kilometres of + * empty coastline apart, with no sane radius that covers both. The requested + * region supplies the centre; the radius stays what the operator configured. + * + * ### Staying inside adsb.lol's limits + * + * adsb.lol asks for **no more than one request per second** from a client and + * says outright that it will rate-limit callers who ignore it; airplanes.live, + * which serves the same shape at `TERA_ADSB_ENDPOINT`, publishes the same + * ceiling. Both are volunteer-fed community feeds paid for by people who did not + * sign up to host somebody's map. + * + * This stays inside the limit structurally rather than by hoping: + * + * - One poll per region per `liveTtl`, never per request. Concurrent misses on a + * region collapse into one upstream call (`upstream.ts`). + * - `liveTtl` is **floored at 5 seconds** as well as capped at 15. The floor is + * the load-bearing half and it is new: `TERA_FLIGHTS_TTL=0` used to mean one + * upstream request per inbound request, which is exactly the flood the limit + * exists to stop, delivered by a config value that reads like "as fresh as + * possible". + * - The region set is a fixed allowlist from the environment, so the worst case + * is arithmetic rather than a guess: **regions ÷ 5 requests per second**, with + * every region under continuous load. With the two this repo ships that is + * 0.4/s against a ceiling of 1/s. An operator who configures more than five + * regions and keeps them all warm is the case to watch, and writing the number + * down here is how they find that out before adsb.lol does. + * - A box nobody is looking at polls nothing at all. */ import type { Config } from "../config.ts"; +import type { Region } from "../regions.ts"; import type { FlightsBody } from "../../../src/server/wire.ts"; +import { createUpstream } from "../upstream.ts"; import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts"; import { planFor } from "./plan.ts"; export interface FlightsService { - current(): Promise; + current(region: Region): Promise; } export interface FlightsLog { @@ -24,48 +57,61 @@ export interface FlightsLog { /** * How long a live snapshot may be cached. Aircraft move; the plan does not, so - * only the live path is clamped. + * only the live path is clamped. See the rate-limit note above for the floor. */ const LIVE_MAX_TTL_SECONDS = 15; +const LIVE_MIN_TTL_SECONDS = 5; + +/** + * The one cache key every `dump1090` region shares. + * + * A receiver has one antenna. Asking a Bay Area rooftop for the Southland gets + * the Bay Area sky whatever the query said, so keying this per region would + * multiply the file reads without changing a byte of the answer. The honest + * thing is one key and a snapshot whose aircraft carry their own coordinates — + * the renderer puts them where they actually are. + */ +const RECEIVER_KEY = "receiver"; export function createFlightsService(config: Config, log: FlightsLog): FlightsService { const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights; - const routes = planFor(config.origin); - const plan = (): FlightsBody => ({ - mode: "plan", - source: "sim", - t0: epochMs, - seed, - routes, - ttlSeconds, + // Built once per region and kept: the plan is a pure function of the centre, + // and it is handed out on every cacheable request. + const plans = new Map(); + const plan = (region: Region): FlightsBody => { + const existing = plans.get(region.id); + if (existing !== undefined) return existing; + const body: FlightsBody = { + mode: "plan", + source: "sim", + t0: epochMs, + seed, + routes: planFor(region), + ttlSeconds, + }; + plans.set(region.id, body); + return body; + }; + + const liveTtl = Math.min(LIVE_MAX_TTL_SECONDS, Math.max(LIVE_MIN_TTL_SECONDS, ttlSeconds)); + const upstream = createUpstream({ + label: `flights:${source}`, + ttlSeconds: liveTtl, + log, }); - const liveTtl = Math.min(ttlSeconds, LIVE_MAX_TTL_SECONDS); - let snapshot: FlightsSnapshot | null = null; - let polledAt = 0; - - async function poll(): Promise { - const fresh = - source === "dump1090" - ? await readDump1090(dump1090Path) - : await fetchAdsb(endpoint, config.origin, radiusNm); - polledAt = Date.now(); - if (fresh !== null) { - snapshot = fresh; - return; - } - log.warn( - `flights: ${source} did not answer; serving ${snapshot === null ? "the simulated plan" : "the last snapshot"}`, - ); - } - return { - async current(): Promise { - if (source === "sim") return plan(); + async current(region: Region): Promise { + if (source === "sim") return plan(region); - if (Date.now() - polledAt > liveTtl * 1000) await poll(); - if (snapshot === null) return plan(); + const key = source === "dump1090" ? RECEIVER_KEY : region.id; + const snapshot = await upstream.get(key, () => + source === "dump1090" + ? readDump1090(dump1090Path, log) + : fetchAdsb(endpoint, region, radiusNm, log), + ); + if (snapshot === null) return plan(region); return { mode: "live", diff --git a/server/src/flights/plan.ts b/server/src/flights/plan.ts index 113a752..129c9af 100644 --- a/server/src/flights/plan.ts +++ b/server/src/flights/plan.ts @@ -11,11 +11,12 @@ * * ### Where the coordinates came from * - * The three Bay Area airport positions are published FAA airport reference - * points, typed in by hand. They are US government facts in the public domain, - * and — as with every other coordinate in this repo — emphatically not derived - * from OpenStreetMap. Everything else here is a waypoint someone made up so the - * legs go the right way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8. + * The seven airport positions — SFO, OAK and SJC in the north, LAX, BUR, LGB and + * SNA in the south — are published FAA airport reference points, typed in by + * hand. They are US government facts in the public domain, and — as with every + * other coordinate in this repo — emphatically not derived from OpenStreetMap. + * Everything else here is a waypoint someone made up so the legs go the right + * way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8. * * The callsigns are invented, with operator prefixes that belong to nobody. A * repo that refuses to ship other people's logos should not ship their flight @@ -95,17 +96,100 @@ const BAY_AREA: WireSimRoute[] = [ }, ]; -/** Degrees, roughly the distance from downtown SF to the far end of the bay. */ -const BAY_AREA_RADIUS = 0.75; +const LAX: [number, number] = [33.9425, -118.4081]; +const BUR: [number, number] = [34.2007, -118.3585]; +const LGB: [number, number] = [33.8177, -118.1516]; +const SNA: [number, number] = [33.6757, -117.8682]; + +/** + * The Southland, which is a different shape of airspace and not a translated + * copy of the Bay Area. + * + * Almost everything here runs east–west, because the terrain does: the basin is + * walled by the San Gabriels to the north, so departures go out over the water + * and turn, and arrivals come down the length of the valley. Four fields instead + * of three, and the two small ones matter more than they do in the north — a + * board four times the area at a third of the scale reads as empty very fast if + * everything is at cruise. + */ +const SOCAL: WireSimRoute[] = [ + // Departures — the standard west-over-the-ocean climb, the north-east haul + // over the Cajon Pass, and the two valley fields going their own way. + { callsign: "LMB604", from: LAX, to: [33.6, -119.3], fromAlt: 20, toAlt: 10500, duration: 440 }, + { callsign: "PAC712", from: LAX, to: [34.6, -117.1], fromAlt: 20, toAlt: 11500, duration: 520 }, + { callsign: "SIE288", from: BUR, to: [34.9, -118.9], fromAlt: 20, toAlt: 9800, duration: 460 }, + { callsign: "BAY1516", from: SNA, to: [33.1, -118.6], fromAlt: 20, toAlt: 9600, duration: 420 }, + { callsign: "GLD843", from: LGB, to: [34.5, -116.9], fromAlt: 20, toAlt: 10200, duration: 500 }, + + // Arrivals — the long straight-in from the east, the coastal descent from the + // north-west, and one down the back of the mountains into Burbank. + { callsign: "LMB1170", from: [34.1, -116.8], to: LAX, fromAlt: 6200, toAlt: 20, duration: 560 }, + { callsign: "RDW425", from: [34.7, -119.4], to: LAX, fromAlt: 6800, toAlt: 20, duration: 600 }, + { callsign: "PAC96", from: [34.8, -117.6], to: BUR, fromAlt: 5400, toAlt: 20, duration: 480 }, + { callsign: "SIE1901", from: [33.2, -117.2], to: SNA, fromAlt: 5000, toAlt: 20, duration: 450 }, + + // Overflights, level the whole way: the coastal corridor and the desert one. + { + callsign: "GLD1330", + from: [34.9, -119.2], + to: [32.9, -117.0], + fromAlt: 11200, + toAlt: 11200, + duration: 690, + }, + { + callsign: "RDW78", + from: [33.0, -119.1], + to: [34.9, -116.8], + fromAlt: 10600, + toAlt: 10600, + duration: 720, + }, + + // Low across the basin. General aviation is most of what anybody standing in + // Culver City actually sees, and it is the only traffic that reads as *near*. + { + callsign: "BAY3312", + from: [33.78, -118.42], + to: [34.16, -117.75], + fromAlt: 850, + toAlt: 850, + duration: 540, + }, + { + callsign: "LMB2044", + from: [34.22, -118.72], + to: [33.72, -117.98], + fromAlt: 1300, + toAlt: 1300, + duration: 570, + }, +]; + +/** + * The airspaces this file has actually laid out, and how close a region's centre + * has to be to get one. + * + * Degrees rather than kilometres because the comparison is a box, not a circle, + * and the radii are generous: the Bay Area's is roughly downtown to the far end + * of the bay, and the Southland's spans a basin that runs from Ventura to + * Riverside. A region centre that lands inside one of these is close enough that + * the real airports are the right answer; anything else gets spokes. + */ +const AIRSPACES: { centre: [number, number]; radius: number; routes: WireSimRoute[] }[] = [ + { centre: [37.7749, -122.4194], radius: 0.75, routes: BAY_AREA }, + { centre: [33.82, -118.05], radius: 1.0, routes: SOCAL }, +]; /** * A plan for a city this file has never heard of. * - * A self-hoster pointing `TERA_ORIGIN_LAT/LNG` at somewhere that is not San - * Francisco should get moving aircraft rather than an empty sky, so eight legs - * are laid out on evenly-spaced bearings through their origin. It is not their - * city's real airspace and does not pretend to be — it is motion in the right - * kind of place, which is all the map ever wanted from this. + * A self-hoster pointing `TERA_REGIONS` (or `TERA_ORIGIN_LAT/LNG`) at somewhere + * that is neither of the two boards should get moving aircraft rather than an + * empty sky, so eight legs are laid out on evenly-spaced bearings through the + * region centre. It is not their city's real airspace and does not pretend to be + * — it is motion in the right kind of place, which is all the map ever wanted + * from this. */ function genericPlan(lat: number, lng: number): WireSimRoute[] { const routes: WireSimRoute[] = []; @@ -127,9 +211,15 @@ function genericPlan(lat: number, lng: number): WireSimRoute[] { return routes; } -export function planFor(origin: { lat: number; lng: number }): WireSimRoute[] { - const nearSf = - Math.abs(origin.lat - 37.7749) < BAY_AREA_RADIUS && - Math.abs(origin.lng + 122.4194) < BAY_AREA_RADIUS; - return nearSf ? BAY_AREA : genericPlan(origin.lat, origin.lng); +export function planFor(centre: { lat: number; lng: number }): WireSimRoute[] { + for (const airspace of AIRSPACES) { + const [lat, lng] = airspace.centre; + if ( + Math.abs(centre.lat - lat) < airspace.radius && + Math.abs(centre.lng - lng) < airspace.radius + ) { + return airspace.routes; + } + } + return genericPlan(centre.lat, centre.lng); } diff --git a/server/src/http.ts b/server/src/http.ts index 3611d3d..5117d55 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -9,28 +9,91 @@ const DEFAULT_TIMEOUT_MS = 6000; +/** + * The largest upstream body this service will read. + * + * "Every outbound call is bounded" was true of the *time* and not of the size: + * a bare `res.json()` reads whatever arrives, and what arrives is chosen by + * somebody else's server. An ADS-B endpoint answering with 200,000 aircraft was + * measured at 14.9 MB, parsed into an array this process then held and served + * — cached, publicly — to every anonymous caller for the length of the TTL. + * + * Four megabytes is roughly two orders of magnitude above any honest answer + * from the four upstreams here (a busy adsb.lol circle is tens of kilobytes, an + * NWS observation is under ten) and well under anything that would trouble the + * heap. It bounds the damage; `flights/adsb.ts` caps the row count, which + * bounds what is kept. + */ +const MAX_BODY_BYTES = 4 * 1024 * 1024; + export interface GetJsonOptions { headers?: Record; timeoutMs?: number; + /** Body-size ceiling in bytes. Defaults to `MAX_BODY_BYTES`. */ + maxBytes?: number; } /** - * `null` on any failure at all — transport, status, or unparseable body. The - * caller decides what a missing answer means; nothing here does. + * `null` on any failure at all — transport, status, an oversized body, or one + * that will not parse. The caller decides what a missing answer means; nothing + * here does. */ export async function getJson(url: string, opts: GetJsonOptions = {}): Promise { + const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES; try { const res = await fetch(url, { headers: { accept: "application/json", ...opts.headers }, signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), }); if (!res.ok) return null; - return (await res.json()) as T; + + /** + * The header first, because it is free and it is the one that stops the + * transfer before it happens. It is only advisory — a chunked response + * sends none — so the body is counted as it streams as well, and the + * `cancel()` closes the socket on a server that lied or did not say. + */ + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + await res.body?.cancel(); + return null; + } + + const text = await readBounded(res, maxBytes); + if (text === null) return null; + return JSON.parse(text) as T; } catch { return null; } } +/** The body as text, or `null` the moment it goes over `maxBytes`. */ +async function readBounded(res: Response, maxBytes: number): Promise { + const body = res.body; + // Undici always gives a stream; a test double or a `fetch` polyfill may not, + // and falling back to `res.text()` there is still bounded by the header check + // above and by the timeout. + if (!body) { + const text = await res.text(); + return text.length > maxBytes ? null : text; + } + const reader = body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + return null; + } + out += decoder.decode(value, { stream: true }); + } + return out + decoder.decode(); +} + /** * A User-Agent that identifies this software and the operator running it. * diff --git a/server/src/markers/store.ts b/server/src/markers/store.ts index abf52c3..1269726 100644 --- a/server/src/markers/store.ts +++ b/server/src/markers/store.ts @@ -9,9 +9,42 @@ * Private per-user markers never appear here at all. An authenticated browser * calls Workie directly with its own token, so private rows never transit this * process. CONTRACT.md §5. + * + * ### What the file looks like + * + * ```json + * { "generatedAt": "2026-08-05T09:00:00Z", + * "markers": [ + * { "id": "ferry-building", "label": "Ferry Building", "colorKey": "sector.civic", + * "lat": 37.7955, "lng": -122.3937, "provenance": "hand-placed" } + * ] } + * ``` + * + * A bare array is accepted too, because it is the obvious thing to hand-write + * and a self-hoster's first marker file should not need a wrapper object. Every + * row goes through `gate.ts`, which refuses — row by row, never repairing — + * anything carrying a field nobody reviewed and anything whose `provenance` is + * not on the allowlist. The counts come back on the wire in `refused`, because a + * silent drop looks exactly like an empty database. + * + * ### Reloading without a restart + * + * `TERA_MARKERS_TTL` is the reload interval, not just a cache lifetime: once it + * expires, the next request `stat`s the file and re-reads it only if the mtime + * or the size moved. That is what makes a short TTL affordable — five seconds on + * an unchanged file costs one `stat` every five seconds — so `npm run sync` + * followed by its atomic rename is visible on the map within seconds, with + * nothing restarted and nothing signalled. + * + * The other half is that **a read that fails keeps the last good snapshot**. + * This used to replace the cache with an empty body, so a single unreadable read + * — the file being rewritten by something less careful than the sync oneshot, a + * permissions change, a full disk — emptied the map for a whole TTL. A stale + * marker set is a far cheaper mistake than a map that quietly lost its markers, + * which is the same trade the sync oneshot makes when it refuses to write. */ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import type { Config } from "../config.ts"; import type { MarkersBody } from "../../../src/server/wire.ts"; import { assertPublicShape } from "./gate.ts"; @@ -29,24 +62,43 @@ interface Snapshot { markers?: unknown; } +/** + * What a configured-but-unreadable file serves before it has ever been read. + * + * It goes out as a refusal rather than as a plain empty list on purpose: an + * operator looking at `/api/v1/markers` and seeing nothing is owed the + * difference between "there are no markers" and "this box cannot read the file + * you pointed it at". Same reasoning as the gate's counts. + */ +function unreadable(file: string): MarkersBody { + return { + markers: [], + generatedAt: new Date().toISOString(), + refused: [{ reason: `the snapshot at ${file} could not be read`, count: 1 }], + }; +} + export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore { const { source, file, provenanceAllowlist, ttlSeconds } = config.markers; let cached: MarkersBody | null = null; let readAt = 0; + /** mtime and size of the file `cached` was built from. Empty means "unknown". */ + let signature = ""; - async function load(): Promise { - const now = new Date().toISOString(); + /** `null` when the file could not be read or parsed; the caller keeps what it has. */ + async function load(): Promise { let parsed: unknown; try { parsed = JSON.parse(await readFile(file, "utf8")); } catch (err) { - log.warn(`markers: cannot read ${file} (${String(err)}); serving no markers`); - return { markers: [], generatedAt: now, refused: [] }; + log.warn( + `markers: cannot read ${file} (${String(err)}); serving ` + + `${cached === null ? "no markers" : "the last snapshot"}`, + ); + return null; } - // A bare array is accepted because it is the obvious thing to hand-write, - // and a self-hoster's first marker file should not need a wrapper object. const snapshot: Snapshot = Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot); @@ -57,21 +109,45 @@ export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore { return { markers: accepted, - generatedAt: snapshot.generatedAt ?? now, + generatedAt: snapshot.generatedAt ?? new Date().toISOString(), refused, }; } + /** + * Whether the file on disk differs from the one behind `cached`. + * + * A failed `stat` answers "yes", so the read is attempted and reports the real + * error once, rather than this function inventing a reason of its own. + */ + async function changed(): Promise { + try { + const info = await stat(file); + const next = `${info.mtimeMs}:${info.size}`; + if (next === signature) return false; + signature = next; + return true; + } catch { + signature = ""; + return true; + } + } + return { async current(): Promise { if (source === "none") { return { markers: [], generatedAt: new Date().toISOString(), refused: [] }; } + if (cached === null || Date.now() - readAt > ttlSeconds * 1000) { - cached = await load(); + if (cached === null || (await changed())) { + const fresh = await load(); + if (fresh !== null) cached = fresh; + } readAt = Date.now(); } - return cached; + + return cached ?? unreadable(file); }, }; } diff --git a/server/src/regions.ts b/server/src/regions.ts new file mode 100644 index 0000000..7924e42 --- /dev/null +++ b/server/src/regions.ts @@ -0,0 +1,332 @@ +/** + * The places this deployment will answer for, and the refusal of everywhere else. + * + * `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred + * kilometres apart with genuinely different skies — the marine-layer comment in + * `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own + * weather, not San Francisco's fog. So weather and flights have to answer for a + * *requested* place rather than for the box's one origin. + * + * ### Why this is an allowlist and not a lookup + * + * The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an + * unauthenticated endpoint into a free geocoding proxy for the whole planet. + * Two things go wrong with that, and neither is hypothetical. It is an + * amplification vector: one cheap request here becomes one expensive request to + * somebody else's public-good API, from an address they will blame. And it is + * how a deployment's User-Agent gets blocked, because NWS's fair-use policy is + * written against exactly this pattern and the contact string in + * `TERA_WEATHER_CONTACT` is the operator's own name on the request. + * + * The rule here is therefore: **a caller's coordinate is never forwarded + * upstream. It only selects among the points the operator configured.** A + * request for Berkeley resolves to the San Francisco region and fetches San + * Francisco's centre; a request for Fresno is refused with a 400 naming what is + * served. The upstream key space is the region list, so it is bounded by the + * environment file and cannot be grown by anybody sending requests — which is + * the property that makes per-region caching, the NWS station cache and the + * adsb.lol poll budget in `flights/adsb.ts` all bounded too. + * + * A coarse grid was the other candidate and was rejected: snapping to 0.5° still + * leaves a caller able to name a hundred thousand distinct cells, which bounds + * nothing that matters. Refusing is the honest answer, and a self-hoster who + * wants their own city writes one line of `TERA_REGIONS`. + */ + +/** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */ +const KM_PER_DEGREE = 111.195; + +/** + * How far from a region's centre a request may land and still resolve there, + * when the operator did not say. + * + * 120 km covers both shipped boards with room to spare — the far corner of the + * Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving + * the two regions comfortably disjoint, since their centres are about 440 km + * apart. It is deliberately not tight: the point of the radius is to refuse + * somewhere this deployment has nothing to say about, not to police the edge of + * the rendered board. + */ +export const DEFAULT_RADIUS_KM = 120; + +export interface Region { + /** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */ + id: string; + lat: number; + lng: number; + /** Kilometres. See `DEFAULT_RADIUS_KM`. */ + radiusKm: number; +} + +/** + * At least one region, always, with the first one being the default. + * + * A tuple rather than an array because "the default region" is read on every + * request that omits a query, and a `Region | undefined` there would be a + * falsehood the type system made everybody handle. + */ +export type RegionSet = [Region, ...Region[]]; + +/** + * The two cities this repo ships, with the centres their packs declare — + * `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`. + * + * They are restated rather than imported for the same reason `wire.ts` restates + * `SimRoute`: a city pack is three thousand lines of coastline for a renderer + * that owns three.js, and the API has one runtime dependency and intends to keep + * it. Two numbers each, and the day a pack moves its centre the weather resolves + * to a point a few kilometres off, which is a rounding error against a radius of + * 120 km. + */ +const SHIPPED: Region[] = [ + { id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM }, + { id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM }, +]; + +/** + * `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar. + * + * The decimal places are capped in the pattern rather than checked afterwards + * because it is the same cap the query parser applies, and the two agreeing is + * the point: a coordinate nobody could ask for is a coordinate nobody should be + * able to configure either. + */ +const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/; + +export interface LoadRegionsOptions { + /** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */ + spec: string; + /** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */ + origin: { lat: number; lng: number }; + /** Whether the operator actually wrote an origin, as opposed to inheriting SF. */ + originConfigured: boolean; + degraded: string[]; +} + +/** + * The region set for this box. + * + * Three cases, in order, and the ordering is what keeps every existing + * deployment answering exactly as it did: + * + * 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order. + * 2. Otherwise the two shipped cities, because that is what the bundled map + * draws and serving only one of them is wrong for half the board. + * 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere + * else gets that point as a region of its own, first in the list and + * therefore the default. A bare `GET /api/v1/weather` on their box answers + * for their origin, exactly as it did before this file existed. If their + * origin already falls inside a shipped region, that region moves to the + * front instead of being duplicated. + * + * Malformed entries are dropped with a line in `degraded` rather than being + * fatal, and a spec in which *nothing* parses falls all the way back to case 2. + * CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot. + */ +export function loadRegions(opts: LoadRegionsOptions): RegionSet { + const configured = parseSpec(opts.spec, opts.degraded); + if (configured.length > 0) return asSet(configured, configured[0] as Region); + + const shipped = SHIPPED.map((region) => ({ ...region })); + const containing = shipped.findIndex((region) => contains(region, opts.origin)); + + if (containing > 0) { + const moved = shipped[containing] as Region; + shipped.splice(containing, 1); + shipped.unshift(moved); + } else if (containing === -1 && opts.originConfigured) { + shipped.unshift({ + id: "origin", + lat: opts.origin.lat, + lng: opts.origin.lng, + radiusKm: DEFAULT_RADIUS_KM, + }); + } + + return asSet(shipped, shipped[0] as Region); +} + +/** + * `Region[]` to `RegionSet`, with the caller supplying the head it has already + * proved is there. The alternative is a cast on the whole array, which would + * also silence the empty case this type exists to rule out. + */ +function asSet(regions: Region[], head: Region): RegionSet { + return [head, ...regions.slice(1)]; +} + +function parseSpec(spec: string, degraded: string[]): Region[] { + const trimmed = spec.trim(); + if (trimmed === "") return []; + + const regions: Region[] = []; + for (const raw of trimmed.split(/[;\n]/)) { + const entry = raw.trim(); + if (entry === "") continue; + + const match = ENTRY.exec(entry); + if (match === null) { + degraded.push( + `TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` + + `\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`, + ); + continue; + } + + const id = match[1] as string; + const lat = Number(match[2]); + const lng = Number(match[3]); + const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]); + + if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) { + degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`); + continue; + } + if (regions.some((region) => region.id === id)) { + degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`); + continue; + } + + regions.push({ id, lat, lng, radiusKm }); + } + + if (regions.length === 0 && trimmed !== "") { + degraded.push( + "TERA_REGIONS was set but nothing in it parsed; serving the two cities the " + + "map ships with instead.", + ); + } + return regions; +} + +// ---- Answering a request -------------------------------------------------- + +/** + * What a route hands in. `unknown` throughout because this is untrusted query + * input: Fastify hands back a string for `?lat=1`, an **array** for + * `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed + * `string` would be a lie the first time somebody repeated a parameter. + */ +export interface RegionQuery { + city?: unknown; + lat?: unknown; + lng?: unknown; +} + +export type RegionResolution = + | { ok: true; region: Region } + /** Already a sentence. The route sends it as `ErrorBody.message`. */ + | { ok: false; message: string }; + +/** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */ +const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/; + +const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/; + +/** + * Turn a query into one of the configured regions, or into a refusal. + * + * Every branch that is not a resolved region is a **400**, not a degraded body. + * That is the one place this file departs from the "degrade, never fail" + * convention, and the distinction is who made the mistake: an upstream that is + * down is not the caller's fault and must not become their problem, whereas + * `?lat=banana` is a client bug that a 200 full of clear sky would hide until + * somebody wondered why the fog never rolls in. + */ +export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution { + const city = query.city; + const hasCity = city !== undefined && city !== ""; + const hasLat = query.lat !== undefined && query.lat !== ""; + const hasLng = query.lng !== undefined && query.lng !== ""; + + if (hasCity && (hasLat || hasLng)) { + return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." }; + } + + if (hasCity) { + if (typeof city !== "string" || !CITY_ID.test(city)) { + return { ok: false, message: `city must be one of: ${served(regions)}.` }; + } + const region = regions.find((candidate) => candidate.id === city); + if (region === undefined) { + return { ok: false, message: `This deployment serves: ${served(regions)}.` }; + } + return { ok: true, region }; + } + + if (hasLat !== hasLng) { + return { ok: false, message: "lat and lng have to be given together." }; + } + + if (!hasLat) return { ok: true, region: regions[0] }; + + const lat = degrees(query.lat, 90); + const lng = degrees(query.lng, 180); + if (lat === null || lng === null) { + return { + ok: false, + message: + "lat and lng must be plain decimal degrees within ±90 and ±180, " + + "with at most six decimal places.", + }; + } + + const region = nearest(regions, lat, lng); + if (region === null) { + return { + ok: false, + message: + `Nothing this deployment serves is near ${lat},${lng}. It serves: ` + + `${served(regions)}. Ask for one of those by id, or add yours to ` + + `TERA_REGIONS on the server.`, + }; + } + return { ok: true, region }; +} + +function served(regions: RegionSet): string { + return regions.map((region) => region.id).join(", "); +} + +function degrees(raw: unknown, limit: number): number | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + // The pattern is doing the work `Number()` would do badly: it rejects `NaN`, + // `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which + // `Number()` either accepts or turns into a value that then has to be + // re-checked. One regex, one meaning. + if (!DEGREES.test(trimmed)) return null; + const value = Number(trimmed); + return Number.isFinite(value) && Math.abs(value) <= limit ? value : null; +} + +/** The closest region that claims the point, or `null` if none of them does. */ +function nearest(regions: RegionSet, lat: number, lng: number): Region | null { + let best: Region | null = null; + let bestKm = Infinity; + for (const region of regions) { + const km = distanceKm(region, lat, lng); + if (km <= region.radiusKm && km < bestKm) { + best = region; + bestKm = km; + } + } + return best; +} + +/** + * Equirectangular rather than haversine, because at these distances the error is + * under half a percent and the comparison it feeds is against a radius chosen to + * the nearest ten kilometres. Trigonometry accurate to the metre would be + * decorating a threshold that is deliberately fuzzy. + */ +function distanceKm(region: Region, lat: number, lng: number): number { + const dLat = lat - region.lat; + const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180; + const dLng = (lng - region.lng) * Math.cos(meanLat); + return Math.hypot(dLat, dLng) * KM_PER_DEGREE; +} + +function contains(region: Region, point: { lat: number; lng: number }): boolean { + return distanceKm(region, point.lat, point.lng) <= region.radiusKm; +} diff --git a/server/src/routes/flights.ts b/server/src/routes/flights.ts index 004df17..895104d 100644 --- a/server/src/routes/flights.ts +++ b/server/src/routes/flights.ts @@ -1,18 +1,48 @@ /** - * `GET /api/v1/flights`. + * `GET /api/v1/flights` — for a city, not for the box. + * + * `?city=socal`, or `?lat=&lng=` resolved against the same allowlist the weather + * route uses, or neither for the default region. `regions.ts` owns the + * validation, the refusal, and the reasoning behind refusing at all: a live + * traffic endpoint that will fetch any coordinate on demand is an amplifier + * pointed at a volunteer-funded feed. * * Publicly cacheable, because the whole design of the plan is that one response - * serves every viewer for its whole TTL. Aircraft are not personal data and this - * body never varies by who asked. + * serves every viewer of a region for its whole TTL. Aircraft are not personal + * data and this body never varies by who asked — only by where. + * + * ### `radiusNm` on the query is ignored, deliberately + * + * The browser sends one. It is dropped, and the size of the circle stays + * `TERA_ADSB_RADIUS_NM`, for a reason that is not stubbornness: the cache key + * would have to include it, and a caller who can choose the key can make this + * box hold an unbounded number of entries and issue an unbounded number of + * distinct upstream requests — each one more expensive than the last, since a + * wider circle is more work for the feed to answer. Every bound in + * `flights/index.ts` and `regions.ts` rests on the key space being the operator's + * region list, and a query parameter that widens it dissolves all of them. + * + * An operator whose board is bigger than the circle raises + * `TERA_ADSB_RADIUS_NM`; 60 nm covers both shipped cities. The client already + * discards aircraft outside the region it drew, so a circle that is too large + * costs a little bandwidth and nothing else. */ import type { FastifyInstance } from "fastify"; import { publicCache } from "../cache.ts"; +import { resolveRegion, type RegionQuery } from "../regions.ts"; +import type { ErrorBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; export function registerFlights(app: FastifyInstance, services: Services): void { - app.get("/api/v1/flights", async (req, reply) => { - const body = await services.flights.current(); + app.get<{ Querystring: RegionQuery }>("/api/v1/flights", async (req, reply) => { + const resolved = resolveRegion(services.config.regions, req.query); + if (!resolved.ok) { + const error: ErrorBody = { error: "bad_request", message: resolved.message }; + return reply.code(400).send(error); + } + + const body = await services.flights.current(resolved.region); publicCache(req, reply, body.ttlSeconds); return body; }); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index ff02684..d39e673 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -10,17 +10,37 @@ * `degraded` is what makes it more than a liveness probe: every demotion the * config made is printed here, so "why is the weather always clear" has an * answer that does not require log access. + * + * `regions` joins it for the same reason. Weather and flights now refuse a place + * this box does not serve, so a client that guesses `?city=` and a 400 it cannot + * explain is the failure this field prevents: ask health once, learn what may be + * asked for, and an operator diagnosing "why is there no SoCal weather" reads + * the answer instead of the env file. Publishing the allowlist gives nothing + * away — knowing what is served is not the same as widening it, and the ids are + * the names of the cities the map already draws. */ import type { FastifyInstance } from "fastify"; +import type { Region } from "../regions.ts"; import type { HealthBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; +/** + * `HealthBody` plus the served regions. + * + * The field belongs in `src/server/wire.ts` beside the body it extends, and it + * is stated here only because that file is the browser side of this change and + * lands with it. Fold `regions: Region[]` into `HealthBody` and this alias goes + * away; nothing else has to move, because the shape is already exactly what the + * route serves. + */ +type HealthBodyWithRegions = HealthBody & { regions: Region[] }; + export function registerHealth(app: FastifyInstance, services: Services): void { const { config, startedAt } = services; app.get("/api/v1/health", async () => { - const body: HealthBody = { + const body: HealthBodyWithRegions = { ok: true, service: "tera-api", version: config.version, @@ -36,6 +56,10 @@ export function registerHealth(app: FastifyInstance, services: Services): void { ? config.auth.entryUrl : null, }, + // In the config's order, so the first entry is the region a request with + // no query gets. A client reading this can pick its default the same way + // the server does. + regions: config.regions, degraded: config.degraded, }; return body; diff --git a/server/src/routes/markers.ts b/server/src/routes/markers.ts index 0ed102b..489101d 100644 --- a/server/src/routes/markers.ts +++ b/server/src/routes/markers.ts @@ -1,24 +1,73 @@ /** - * `GET /api/v1/markers`. + * `GET /api/v1/markers` — the one route the `member` tier is about. * * The public snapshot, and nothing else. Private per-user markers are never * proxied through this box — an authenticated browser calls Workie directly with * its own token, so a private row never enters this process and cannot leave it. - * CONTRACT.md §5. + * CONTRACT.md §5. Every row served here has been through the provenance gate in + * `markers/gate.ts`, which is what keeps a public snapshot from quietly becoming + * a Publicly Used Derivative Database under ODbL. * - * Every row served here has been through the provenance gate in `markers/gate.ts`, - * which is what keeps a public snapshot from quietly becoming a Publicly Used - * Derivative Database under ODbL. + * ### Why a configured feed is refused to anonymous callers + * + * `src/access.ts` has three tiers and, until this route, the middle one gated + * nothing at all: `member` was a word in a type union that no server behaviour + * corresponded to. A tier that never refuses anybody anything is not a tier, and + * one that lives only in the client is worse — it is a UI hiding a control over + * a body the API hands to whoever asks. **So: where a marker feed is configured, + * it takes a session.** That refusal is the whole of what makes membership real, + * it is enforced here rather than drawn in the browser, and there is + * deliberately no `TERA_MARKERS_PUBLIC` escape hatch to undo it. + * + * Two consequences worth stating out loud: + * + * - **A box with no feed still answers 200 and an empty list.** `source: none` + * is the zero-config default and there is nothing there to protect; making a + * stranger sign in to be told "no markers" would fail the acceptance test in + * `boot.test.ts` and gain nobody anything. + * - **A feed with `TERA_AUTH_MODE=none` is unreachable by everyone**, because + * nobody on such a box is ever authenticated. That is the fail-closed + * direction, it is the one private offices already take, and `config.ts` + * pushes a line into `degraded` saying so rather than letting an operator + * discover it from an empty map. + * + * ### 401 here, 404 for an office + * + * `offices.ts` answers 404 for a private office because a 403 there is an + * enumeration oracle — walk the id space, read the status codes, learn every + * tenant. Nothing is enumerable here: there is one feed, at a fixed path, and + * `/api/v1/health` already publishes `sources.markers`, so whether this + * deployment has markers is not a secret being kept. What the caller needs to + * know is "sign in and ask again", which is what 401 with `WWW-Authenticate` + * says. A 404 would be a lie told to protect nothing. */ import type { FastifyInstance } from "fastify"; import { publicCache } from "../cache.ts"; +import type { ErrorBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; +const UNAUTHORIZED: ErrorBody = { + error: "unauthorized", + message: "The marker feed is for signed-in members.", +}; + export function registerMarkers(app: FastifyInstance, services: Services): void { app.get("/api/v1/markers", async (req, reply) => { - const body = await services.markers.current(); - publicCache(req, reply, services.config.markers.ttlSeconds); - return body; + if (services.config.markers.source === "none") { + const body = await services.markers.current(); + publicCache(req, reply, services.config.markers.ttlSeconds); + return body; + } + + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) { + return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); + } + + // No `publicCache`. This body took a credential to obtain, and a shared + // cache holding it would hand one member's copy to the next caller — the + // exact thing the fail-closed default in `cache.ts` exists to prevent. + return services.markers.current(); }); } diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts index 1b64dd3..acd25f2 100644 --- a/server/src/routes/weather.ts +++ b/server/src/routes/weather.ts @@ -1,19 +1,40 @@ /** - * `GET /api/v1/weather`. + * `GET /api/v1/weather` — for a place, not for the box. * - * Always 200, always a body. A source that is down, misconfigured or absent - * produces `synthetic: true` and a clear day — there is no failure mode here in - * which the caller has to decide what to render, because the answer to "what is - * the sky doing" is never allowed to be a 503. + * `?city=sf`, or `?lat=&lng=` which resolves to whichever configured region + * claims the point, or neither, which answers for the default region. The + * validation and the refusal both live in `regions.ts`; the paragraph there on + * why this is an allowlist rather than a lookup is the one worth reading before + * touching this file. + * + * Two kinds of answer, and the split is deliberate: + * + * - **A bad request is a 400.** `?lat=banana`, or a coordinate in a city this + * deployment does not serve, is a client bug, and a 200 full of clear sky + * would hide it until somebody wondered why the fog never rolled in. + * - **Everything else is a 200 with a body.** A source that is down, + * misconfigured or absent produces `synthetic: true` and a clear day. There is + * no failure mode in which the caller has to decide what to render, because + * the answer to "what is the sky doing" is never allowed to be a 503. */ import type { FastifyInstance } from "fastify"; import { publicCache } from "../cache.ts"; +import { resolveRegion, type RegionQuery } from "../regions.ts"; +import type { ErrorBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; export function registerWeather(app: FastifyInstance, services: Services): void { - app.get("/api/v1/weather", async (req, reply) => { - const body = await services.weather.current(); + app.get<{ Querystring: RegionQuery }>("/api/v1/weather", async (req, reply) => { + const resolved = resolveRegion(services.config.regions, req.query); + if (!resolved.ok) { + const error: ErrorBody = { error: "bad_request", message: resolved.message }; + return reply.code(400).send(error); + } + + const body = await services.weather.current(resolved.region); + // Query strings are part of a shared cache's key, so two regions cannot + // collide here and no extra `Vary` is owed. publicCache(req, reply, services.config.weather.ttlSeconds); return body; }); diff --git a/server/src/test/flights.test.ts b/server/src/test/flights.test.ts new file mode 100644 index 0000000..9164f7d --- /dev/null +++ b/server/src/test/flights.test.ts @@ -0,0 +1,257 @@ +/** + * Live traffic, and the budget that keeps it welcome. + * + * adsb.lol is a volunteer-fed community feed that asks for no more than one + * request per second and will rate-limit a caller who ignores it. Most of this + * file is therefore about **how often this server is capable of calling out**, + * not about what comes back: one poll per region per TTL, a TTL with a floor + * under it, and a refused query that costs the upstream nothing. The arithmetic + * those tests pin down is written out in `flights/index.ts`. + * + * The one that is not about rate is the URL assertion. A caller's coordinate + * must select a configured region and must never itself be fetched. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { FlightsBody } from "../../../src/server/wire.ts"; + +const realFetch = globalThis.fetch; +let calls: string[] = []; +let feedIsUp = true; +/** A body to serve instead of the usual one, for the wrong-shape tests. */ +let feedOverride: unknown = undefined; + +/** One aircraft, whose id encodes the point that was asked about. */ +function feed(url: string): unknown | undefined { + if (!feedIsUp) return undefined; + const point = /\/v2\/point\/(-?[\d.]+)\/(-?[\d.]+)\/(\d+)$/.exec(url); + if (point === null) return undefined; + if (feedOverride !== undefined) return feedOverride; + return { + now: 1_770_000_000_000, + ac: [{ hex: `a${point[1]}`, flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }], + }; +} + +globalThis.fetch = (async (input: unknown) => { + const url = String(input); + calls.push(url); + const body = feed(url); + if (body === undefined) return new Response("nope", { status: 503 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +}) as unknown as typeof globalThis.fetch; + +after(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + calls = []; + feedIsUp = true; + feedOverride = undefined; +}); + +function appWith(env: Record) { + const config = loadConfig(env); + config.logLevel = "silent"; + return buildApp(config); +} + +const adsbEnv = { TERA_FLIGHTS_SOURCE: "adsb" }; + +describe("the ADS-B source", () => { + it("polls each city's own centre, and never the caller's coordinate", async () => { + const app = appWith(adsbEnv); + after(() => app.close()); + + // Pasadena, which is inside the Southland region and is not a point this + // deployment serves. + await app.inject({ method: "GET", url: "/api/v1/flights?lat=34.1478&lng=-118.1445" }); + await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" }); + + assert.deepEqual(calls, [ + "https://api.adsb.lol/v2/point/33.8200/-118.0500/40", + "https://api.adsb.lol/v2/point/37.7749/-122.4194/40", + ]); + }); + + it("clamps a radius the feeds would reject, and says so", async () => { + const config = loadConfig({ ...adsbEnv, TERA_ADSB_RADIUS_NM: "2500" }); + config.logLevel = "silent"; + const app = buildApp(config); + after(() => app.close()); + + assert.equal(config.flights.radiusNm, 250); + assert.match(config.degraded[0] ?? "", /TERA_ADSB_RADIUS_NM/); + + await app.inject({ method: "GET", url: "/api/v1/flights" }); + assert.equal(calls[0], "https://api.adsb.lol/v2/point/37.7749/-122.4194/250"); + }); + + it("polls once per region per TTL, whatever the request rate is", async () => { + const app = appWith(adsbEnv); + after(() => app.close()); + + const ask = (city: string) => + app.inject({ method: "GET", url: `/api/v1/flights?city=${city}` }); + await Promise.all([ask("sf"), ask("sf"), ask("socal"), ask("sf"), ask("socal")]); + for (let i = 0; i < 6; i++) await ask("sf"); + + // Two regions, eleven requests, two upstream calls. + assert.equal(calls.length, 2); + }); + + it("floors the poll interval so TERA_FLIGHTS_TTL=0 is not a flood", async () => { + // This is the bug the floor exists for: a zero TTL used to mean one + // outbound request per inbound request, straight through the rate limit, + // from a setting that reads like "as fresh as possible". + const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "0" }); + after(() => app.close()); + + let body: FlightsBody | null = null; + for (let i = 0; i < 8; i++) { + body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + } + assert.equal(calls.length, 1); + assert.ok(body !== null && body.mode === "live" && body.ttlSeconds === 5); + }); + + it("caps the poll interval too, because aircraft move", async () => { + const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "3600" }); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "live" && body.ttlSeconds === 15); + }); + + it("costs the upstream nothing when the query is refused", async () => { + const app = appWith(adsbEnv); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/flights?city=atlantis" }); + assert.equal(res.statusCode, 400); + assert.deepEqual(calls, []); + }); + + it("falls back to the simulated plan rather than to an empty sky", async () => { + feedIsUp = false; + const app = appWith(adsbEnv); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.equal(body.mode, "plan"); + assert.ok(body.mode === "plan" && body.routes.length > 0); + }); + + it("credits the feed it took the positions from", async () => { + const app = appWith(adsbEnv); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "live"); + assert.match(body.attribution?.[0] ?? "", /adsb\.lol/); + }); +}); + +/** + * The failure that turns a rate limit inside out. + * + * A feed that goes *down* is handled everywhere. A feed that stays up and + * answers 200 with a field of the wrong type used to throw out of `normalise`, + * past `upstream.ts` — which then never stamped its clock — and out to the + * route as a 500. The TTL is the only rate limit on outbound calls, so losing it + * meant one request to adsb.lol per inbound request, from the operator's + * address, for as long as the feed stayed broken. + */ +describe("a feed that changed shape", () => { + it("answers with the plan instead of a 500", async () => { + // Valid JSON, wrong shape: `ac` is a number where an array belongs. + feedOverride = { ac: 5, now: 1 }; + const app = appWith(adsbEnv); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/flights" }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().mode, "plan"); + }); + + it("still polls once per TTL, which is the whole of the rate limit", async () => { + feedOverride = { ac: 5, now: 1 }; + const app = appWith(adsbEnv); + after(() => app.close()); + + for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/flights" }); + assert.equal(calls.length, 1); + }); + + it("survives rows that are not objects", async () => { + feedOverride = { ac: [null, 7, "LMB1", { hex: "ok", lat: 37.5, lon: -122.3 }], now: 1 }; + const app = appWith(adsbEnv); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "live" && body.aircraft.length === 1); + }); + + it("caps how much of somebody else's sky it will hold and serve", async () => { + feedOverride = { + now: 1, + ac: Array.from({ length: 6000 }, (_, i) => ({ + hex: `x${i}`, + lat: 37.5, + lon: -122.3, + alt_baro: 1000, + })), + }; + const app = appWith(adsbEnv); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "live" && body.aircraft.length === 5000); + }); +}); + +describe("a local receiver", () => { + let path = ""; + + before(async () => { + const dir = await mkdtemp(join(tmpdir(), "tera-flights-")); + path = join(dir, "aircraft.json"); + await writeFile( + path, + JSON.stringify({ + now: 1_770_000_000, + aircraft: [{ hex: "abc123", flight: "LMB9 ", lat: 37.6, lon: -122.4, alt_baro: 3000 }], + }), + ); + }); + + it("has one antenna, so every region reads the same snapshot once", async () => { + const app = appWith({ TERA_FLIGHTS_SOURCE: "dump1090", TERA_DUMP1090_PATH: path }); + after(() => app.close()); + + const sf = ( + await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" }) + ).json(); + assert.ok(sf.mode === "live" && sf.aircraft.length === 1); + + // Take the file away, then ask for the other city inside the TTL. A live + // answer proves the two regions share one cache entry — a per-region key + // would have gone back to disk here and found nothing. + await rm(path); + const socal = ( + await app.inject({ method: "GET", url: "/api/v1/flights?city=socal" }) + ).json(); + assert.ok(socal.mode === "live"); + assert.deepEqual(socal.aircraft, sf.aircraft); + }); +}); diff --git a/server/src/test/markers.test.ts b/server/src/test/markers.test.ts new file mode 100644 index 0000000..d5bfc78 --- /dev/null +++ b/server/src/test/markers.test.ts @@ -0,0 +1,263 @@ +/** + * The marker feed: the refusal that makes the `member` tier real, and a file an + * operator can actually use. + * + * The first half is one assertion said several ways. `src/access.ts` has three + * tiers and the middle one gated nothing — `member` was a word in a type union + * with no server behaviour behind it. A tier that never refuses anybody is not a + * tier, so a configured feed takes a session, and the test that matters is the + * negative: an anonymous caller gets 401 and no rows, with no query parameter, + * header or cleared cookie that changes it. + * + * The second half is the file itself — what a malformed row does to the rest of + * the snapshot, and the reload path, which has to work without a restart because + * the sync oneshot runs on a timer and nothing signals this process. + * + * Follows `offices.test.ts`: a temp directory, `buildApp` over a fake + * environment, `inject()` rather than a socket, and HS256 by hand. + */ + +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { ErrorBody, MarkersBody } from "../../../src/server/wire.ts"; + +const SECRET = "not-a-real-secret-and-never-was"; + +const FERRY = { + id: "ferry-building", + label: "Ferry Building", + colorKey: "sector.civic", + lat: 37.7955, + lng: -122.3937, + provenance: "hand-placed", +}; + +let file = ""; + +before(async () => { + const dir = await mkdtemp(join(tmpdir(), "tera-markers-")); + file = join(dir, "markers.json"); +}); + +beforeEach(async () => { + await write({ generatedAt: "2026-08-05T09:00:00Z", markers: [FERRY] }); +}); + +async function write(snapshot: unknown): Promise { + await writeFile(file, JSON.stringify(snapshot)); +} + +/** A feed that is configured, with an issuer that can produce a member. */ +const feedEnv = { + TERA_MARKERS_SOURCE: "file", + TERA_AUTH_MODE: "jwt", + TERA_AUTH_JWT_SECRET: SECRET, +}; + +function appWith(env: Record) { + const config = loadConfig({ TERA_MARKERS_FILE: file, ...env }); + config.logLevel = "silent"; + return buildApp(config); +} + +function member(): string { + const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const claims = { sub: "someone", exp: Math.floor(Date.now() / 1000) + 600 }; + const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`; + return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`; +} + +function asMember(app: ReturnType) { + return app.inject({ + method: "GET", + url: "/api/v1/markers", + headers: { authorization: `Bearer ${member()}` }, + }); +} + +describe("a configured marker feed", () => { + it("refuses an anonymous caller", async () => { + const app = appWith(feedEnv); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/markers" }); + assert.equal(res.statusCode, 401); + assert.equal(res.json().error, "unauthorized"); + assert.equal(res.headers["www-authenticate"], "Bearer"); + // No rows anywhere in the refusal, and nothing shared may keep it. + assert.ok(!res.body.includes("Ferry")); + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it("refuses every shape of not-being-signed-in", async () => { + const app = appWith(feedEnv); + after(() => app.close()); + + const attempts = [ + {}, + { authorization: "Bearer not-a-jwt" }, + { authorization: `Bearer ${member()}tampered` }, + { authorization: "Basic Zm9vOmJhcg==" }, + { cookie: "tera_session=" }, + { cookie: "tera_session=%zz" }, + ]; + for (const headers of attempts) { + const res = await app.inject({ method: "GET", url: "/api/v1/markers", headers }); + assert.equal(res.statusCode, 401, `${JSON.stringify(headers)} must not get the feed`); + } + }); + + it("serves the snapshot to a member", async () => { + const app = appWith(feedEnv); + after(() => app.close()); + + const res = await asMember(app); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.markers.length, 1); + assert.equal(body.markers[0]?.id, "ferry-building"); + assert.equal(body.generatedAt, "2026-08-05T09:00:00Z"); + }); + + it("never lets a shared cache keep a member's copy", async () => { + const app = appWith(feedEnv); + after(() => app.close()); + assert.equal((await asMember(app)).headers["cache-control"], "private, no-store"); + }); + + it("is unreachable, loudly, on a box where nobody can sign in", async () => { + const config = loadConfig({ TERA_MARKERS_FILE: file, TERA_MARKERS_SOURCE: "file" }); + config.logLevel = "silent"; + const app = buildApp(config); + after(() => app.close()); + + // Fail-closed, and it says so rather than leaving an operator to work it out + // from an empty map. + assert.equal(config.auth.mode, "none"); + assert.ok(config.degraded.some((line) => line.includes("TERA_MARKERS_SOURCE=file"))); + + const anonymous = await app.inject({ method: "GET", url: "/api/v1/markers" }); + assert.equal(anonymous.statusCode, 401); + // Even a token that would be valid elsewhere: mode=none verifies nothing. + assert.equal((await asMember(app)).statusCode, 401); + }); + + it("still answers the public empty body on a box with no feed", async () => { + // The acceptance test's box. There is nothing here to protect, and making a + // stranger sign in to be told "no markers" would gain nobody anything. + const app = appWith({ TERA_MARKERS_SOURCE: "none" }); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/markers" }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().markers, []); + assert.match(String(res.headers["cache-control"]), /^public, max-age=/); + }); +}); + +describe("the marker file itself", () => { + it("accepts a bare array, because that is what a person writes first", async () => { + await write([FERRY]); + const app = appWith(feedEnv); + after(() => app.close()); + + assert.equal((await asMember(app)).json().markers.length, 1); + }); + + it("refuses a bad row without losing the good ones, and reports the count", async () => { + await write({ + markers: [ + FERRY, + { ...FERRY, id: "osm-row", provenance: "nominatim" }, + { ...FERRY, id: "leaky", ownerEmail: "someone@example.com" }, + { ...FERRY, id: "broken", lat: 200 }, + ], + }); + const app = appWith(feedEnv); + after(() => app.close()); + + const body = (await asMember(app)).json(); + assert.deepEqual( + body.markers.map((marker) => marker.id), + ["ferry-building"], + ); + assert.equal(body.refused.length, 3); + assert.ok(body.refused.some((entry) => entry.reason.includes("allowlist"))); + assert.ok(body.refused.some((entry) => entry.reason.includes("ownerEmail"))); + }); + + it("picks up a rewritten file without a restart", async () => { + // TERA_MARKERS_TTL is the reload interval. Zero means every request checks, + // which is what makes this assertable without sleeping for five minutes. + const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" }); + after(() => app.close()); + + assert.equal((await asMember(app)).json().markers.length, 1); + + await write({ markers: [FERRY, { ...FERRY, id: "coit-tower", label: "Coit Tower" }] }); + // A zero TTL means "check on the next millisecond", not "check twice inside + // the same one" — `inject()` is fast enough that both requests can land on + // the same `Date.now()`, which is a property of the test and not of the + // reload. + await sleep(5); + const reloaded = (await asMember(app)).json(); + assert.deepEqual( + reloaded.markers.map((marker) => marker.id), + ["ferry-building", "coit-tower"], + ); + }); + + it("keeps the last good snapshot when a read fails", async () => { + const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" }); + after(() => app.close()); + + assert.equal((await asMember(app)).json().markers.length, 1); + + // The file goes away mid-flight — a rewrite by something less careful than + // the sync oneshot, a permissions change, a full disk. Emptying the map for + // a whole TTL is the expensive mistake; serving what was there is the cheap + // one. + await rm(file); + await sleep(5); + const survived = (await asMember(app)).json(); + assert.equal(survived.markers.length, 1); + + // And it recovers on its own once the file comes back. + await write({ markers: [{ ...FERRY, id: "coit-tower" }] }); + await sleep(5); + const recovered = (await asMember(app)).json(); + assert.deepEqual( + recovered.markers.map((marker) => marker.id), + ["coit-tower"], + ); + }); + + it("says it cannot read the file rather than pretending there are no markers", async () => { + await rm(file); + const app = appWith(feedEnv); + after(() => app.close()); + + const body = (await asMember(app)).json(); + assert.deepEqual(body.markers, []); + assert.equal(body.refused.length, 1); + assert.match(body.refused[0]?.reason ?? "", /could not be read/); + }); + + it("survives a file that is not JSON at all", async () => { + await writeFile(file, "{ this is not json"); + const app = appWith(feedEnv); + after(() => app.close()); + + const res = await asMember(app); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().markers, []); + }); +}); diff --git a/server/src/test/regions.test.ts b/server/src/test/regions.test.ts new file mode 100644 index 0000000..609958b --- /dev/null +++ b/server/src/test/regions.test.ts @@ -0,0 +1,280 @@ +/** + * The served-region allowlist, and everything it refuses. + * + * Two halves. The first is that a box answers for the *places it was configured + * for* rather than for one origin, because the map has two cities six hundred + * kilometres apart and the Bay Area's fog is not Los Angeles's weather. + * + * The second is the one with teeth: **a caller's coordinate must never reach an + * upstream.** An endpoint that fetches any point on demand is an amplifier + * aimed at somebody else's public-good API, with this deployment's contact + * string on every request. So `?lat=&lng=` selects among configured points and + * nothing else, and everything it cannot select is a 400. See the header of + * `regions.ts`; the assertion that this refusal actually holds at the HTTP layer + * is `weather.test.ts`. + */ + +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import { loadRegions, resolveRegion, type RegionSet } from "../regions.ts"; +import type { ErrorBody, FlightsBody, WeatherBody } from "../../../src/server/wire.ts"; + +function appWith(env: Record) { + const config = loadConfig(env); + config.logLevel = "silent"; + return buildApp(config); +} + +/** The two shipped cities, which is what an empty environment resolves to. */ +function shipped(): RegionSet { + return loadConfig({}).regions; +} + +describe("the region set a box ends up with", () => { + it("serves both shipped cities when handed nothing, San Francisco first", () => { + const config = loadConfig({}); + assert.deepEqual( + config.regions.map((region) => region.id), + ["sf", "socal"], + ); + assert.deepEqual(config.degraded, []); + // The default region is still the origin a pre-existing env file named, so + // a bare GET answers exactly as it did before regions existed. + assert.equal(config.regions[0].lat, config.origin.lat); + }); + + it("promotes the shipped city an operator's origin falls inside", () => { + const config = loadConfig({ TERA_ORIGIN_LAT: "34.05", TERA_ORIGIN_LNG: "-118.24" }); + assert.deepEqual( + config.regions.map((region) => region.id), + ["socal", "sf"], + ); + assert.deepEqual(config.degraded, []); + }); + + it("adds an origin that is neither city, and makes it the default", () => { + const config = loadConfig({ TERA_ORIGIN_LAT: "39.7392", TERA_ORIGIN_LNG: "-104.9903" }); + assert.deepEqual( + config.regions.map((region) => region.id), + ["origin", "sf", "socal"], + ); + assert.equal(config.regions[0].lat, 39.7392); + }); + + it("takes TERA_REGIONS literally, in order, with an optional radius", () => { + const config = loadConfig({ + TERA_REGIONS: "pdx:45.5152,-122.6784:80; sea:47.6062,-122.3321", + }); + assert.deepEqual(config.regions, [ + { id: "pdx", lat: 45.5152, lng: -122.6784, radiusKm: 80 }, + { id: "sea", lat: 47.6062, lng: -122.3321, radiusKm: 120 }, + ]); + assert.deepEqual(config.degraded, []); + }); + + it("drops a malformed entry with a sentence and keeps the good ones", () => { + const config = loadConfig({ TERA_REGIONS: "pdx:45.5152,-122.6784; nowhere; sea:200,0" }); + assert.deepEqual( + config.regions.map((region) => region.id), + ["pdx"], + ); + assert.equal(config.degraded.length, 2); + assert.match(config.degraded[0] ?? "", /nowhere/); + assert.match(config.degraded[1] ?? "", /not a place on Earth/); + }); + + it("falls back to the shipped cities when nothing in TERA_REGIONS parses", () => { + const config = loadConfig({ TERA_REGIONS: "?????" }); + assert.deepEqual( + config.regions.map((region) => region.id), + ["sf", "socal"], + ); + assert.ok(config.degraded.some((line) => line.includes("nothing in it parsed"))); + }); + + it("keeps the first of two entries sharing an id", () => { + const config = loadConfig({ TERA_REGIONS: "sf:37.7749,-122.4194; sf:0,0" }); + assert.equal(config.regions.length, 1); + assert.ok(config.degraded.some((line) => line.includes("twice"))); + }); + + it("never ends up with an empty set, whatever it was handed", () => { + for (const spec of ["", " ", ";;;", "sf:", "@:1,2", "x".repeat(200)]) { + const regions = loadRegions({ + spec, + origin: { lat: 37.7749, lng: -122.4194 }, + originConfigured: false, + degraded: [], + }); + assert.ok(regions.length > 0, `"${spec}" produced no regions`); + } + }); +}); + +describe("resolving a request to a region", () => { + const regions = shipped(); + + it("answers for the default when asked for nothing", () => { + const resolved = resolveRegion(regions, {}); + assert.ok(resolved.ok && resolved.region.id === "sf"); + }); + + it("answers for a city by id", () => { + const resolved = resolveRegion(regions, { city: "socal" }); + assert.ok(resolved.ok && resolved.region.id === "socal"); + }); + + it("snaps a nearby coordinate to the region that claims it", () => { + // Berkeley, Pasadena: neither is a configured point, and both resolve to the + // configured point that will actually be fetched. + const berkeley = resolveRegion(regions, { lat: "37.8715", lng: "-122.2730" }); + assert.ok(berkeley.ok && berkeley.region.id === "sf"); + const pasadena = resolveRegion(regions, { lat: "34.1478", lng: "-118.1445" }); + assert.ok(pasadena.ok && pasadena.region.id === "socal"); + }); + + it("refuses a coordinate this deployment has nothing to say about", () => { + for (const point of [ + { lat: "36.7378", lng: "-119.7871" }, // Fresno, between the two boards. + { lat: "40.7128", lng: "-74.0060" }, // New York. + { lat: "0", lng: "0" }, // Null Island, the classic probe. + ]) { + const resolved = resolveRegion(regions, point); + assert.ok(!resolved.ok, `${point.lat},${point.lng} must be refused`); + assert.match(resolved.message, /serves: sf, socal/); + } + }); + + it("refuses a coordinate that is not a coordinate", () => { + for (const lat of [ + "banana", + "NaN", + "Infinity", + "1e5", + "0x2f", + "37.7749deg", + "91", + "37.77490001", + "", + " ", + ]) { + const resolved = resolveRegion(regions, { lat, lng: "-122.4194" }); + assert.ok(!resolved.ok, `lat=${lat} must be refused`); + } + assert.ok(!resolveRegion(regions, { lat: "37.7", lng: "181" }).ok); + }); + + it("refuses a repeated parameter rather than picking one", () => { + // `?lat=1&lat=2` arrives as an array, and quietly taking either half is how + // a parser disagreement becomes a security bug somewhere downstream. + assert.ok(!resolveRegion(regions, { lat: ["37.7", "0"], lng: "-122.4" }).ok); + assert.ok(!resolveRegion(regions, { city: ["sf", "socal"] }).ok); + }); + + it("refuses half a coordinate", () => { + assert.ok(!resolveRegion(regions, { lat: "37.7749" }).ok); + assert.ok(!resolveRegion(regions, { lng: "-122.4194" }).ok); + }); + + it("refuses a request that asks two ways at once", () => { + const resolved = resolveRegion(regions, { city: "sf", lat: "37.7", lng: "-122.4" }); + assert.ok(!resolved.ok); + assert.match(resolved.message, /not both/); + }); + + it("refuses an unknown city without pretending it might exist elsewhere", () => { + const resolved = resolveRegion(regions, { city: "atlantis" }); + assert.ok(!resolved.ok); + assert.match(resolved.message, /sf, socal/); + }); +}); + +describe("the routes that take a region", () => { + for (const route of ["weather", "flights"]) { + it(`serves ${route} for either city, from that city's own point`, async () => { + const app = appWith({}); + after(() => app.close()); + + const sf = await app.inject({ method: "GET", url: `/api/v1/${route}?city=sf` }); + const socal = await app.inject({ method: "GET", url: `/api/v1/${route}?city=socal` }); + assert.equal(sf.statusCode, 200); + assert.equal(socal.statusCode, 200); + assert.notDeepEqual(sf.json(), socal.json()); + }); + + it(`refuses a nonsense ${route} query with a 400 and no cached copy of it`, async () => { + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: `/api/v1/${route}?lat=banana&lng=0` }); + assert.equal(res.statusCode, 400); + assert.equal(res.json().error, "bad_request"); + // The fail-closed default still applies: nothing shared may keep a refusal. + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it(`refuses a ${route} request for somewhere this box does not serve`, async () => { + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: `/api/v1/${route}?lat=51.5072&lng=-0.1276`, + }); + assert.equal(res.statusCode, 400); + assert.match(res.json().message, /sf, socal/); + }); + } + + it("puts the weather where it was asked for, not where the box is", async () => { + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" }); + const body = res.json(); + assert.deepEqual(body.location, { lat: 33.82, lng: -118.05 }); + // Still the synthetic clear day, because a zero-config box has no source. + assert.equal(body.synthetic, true); + }); + + it("flies the Southland's own airports over the Southland", async () => { + const app = appWith({}); + after(() => app.close()); + + const body = ( + await app.inject({ method: "GET", url: "/api/v1/flights?city=socal" }) + ).json(); + assert.ok(body.mode === "plan"); + // LAX's published reference point, as a departure or an arrival. The Bay + // Area plan cannot produce it, which is the whole point of the assertion. + const lax = body.routes.some( + (leg) => + (leg.from[0] === 33.9425 && leg.from[1] === -118.4081) || + (leg.to[0] === 33.9425 && leg.to[1] === -118.4081), + ); + assert.ok(lax, "the SoCal plan should fly out of LAX"); + }); + + it("lays out spokes for a city it has never heard of", async () => { + const app = appWith({ TERA_REGIONS: "pdx:45.5152,-122.6784" }); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "plan" && body.routes.length === 8); + }); + + it("publishes the allowlist on health so a client can stop guessing", async () => { + const app = appWith({}); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<{ + regions: { id: string }[]; + }>(); + assert.deepEqual( + body.regions.map((region) => region.id), + ["sf", "socal"], + ); + }); +}); diff --git a/server/src/test/weather.test.ts b/server/src/test/weather.test.ts new file mode 100644 index 0000000..797b3f9 --- /dev/null +++ b/server/src/test/weather.test.ts @@ -0,0 +1,294 @@ +/** + * Weather, once a source is actually turned on. + * + * `api.weather.gov` is stood up as a stub here rather than called, for the + * obvious reason and for a less obvious one: the assertions that matter are + * about **which URL this server constructs**, and a real upstream would answer + * the right way for the wrong reason. The load-bearing one is that a caller + * asking for Berkeley causes a fetch of San Francisco's point and never of + * Berkeley's — the difference between a map and an open proxy pointed at a + * public-good API with this deployment's contact address on it. + * + * Global `fetch` is replaced for the file. `node --test` runs each test file in + * its own process, so nothing here leaks into another one, and `after` puts the + * real one back anyway. + */ + +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { WeatherBody } from "../../../src/server/wire.ts"; + +const CONTACT = "ops@example.com"; + +const nwsEnv = { TERA_WEATHER_SOURCE: "nws", TERA_WEATHER_CONTACT: CONTACT }; + +interface Call { + url: string; + userAgent: string; +} + +const realFetch = globalThis.fetch; +let calls: Call[] = []; +/** Flipped by a test that wants to watch the upstream go away mid-flight. */ +let upstreamIsUp = true; +/** + * Flipped by a test that wants the upstream to stay *up* and answer 200 with + * something the parser cannot walk. `cloudLayers` becomes a number, which is + * the shape that used to throw straight through the cache. + */ +let upstreamIsGarbled = false; + +/** + * The three hops NWS makes you take, and nothing else: an unrecognised URL is a + * 404, so a request this server should not be making shows up as a failure + * rather than as a plausible answer. + */ +function nws(url: string): unknown | undefined { + if (!upstreamIsUp) return undefined; + + const point = /\/points\/(-?[\d.]+),(-?[\d.]+)$/.exec(url); + if (point !== null) { + return { properties: { observationStations: `https://api.weather.gov/zones/${point[1]}` } }; + } + const zone = /\/zones\/(-?[\d.]+)$/.exec(url); + if (zone !== null) { + // One station id per latitude, so a body can be traced back to the point + // that was asked about. + return { features: [{ properties: { stationIdentifier: `K${zone[1]}` } }] }; + } + const station = /\/stations\/K(-?[\d.]+)\/observations\/latest$/.exec(url); + if (station !== null) { + return { + properties: { + timestamp: "2026-08-05T09:00:00+00:00", + temperature: { value: Number(station[1]), unitCode: "wmoUnit:degC" }, + windSpeed: { value: 9, unitCode: "wmoUnit:km_h-1" }, + cloudLayers: upstreamIsGarbled ? 7 : [{ amount: "BKN" }], + presentWeather: [], + visibility: { value: 16_000, unitCode: "wmoUnit:m" }, + }, + }; + } + return undefined; +} + +globalThis.fetch = (async (input: unknown, init?: { headers?: Record }) => { + const url = String(input); + // `http.ts` always passes a plain object, so this needs no `Headers` dance. + calls.push({ url, userAgent: init?.headers?.["user-agent"] ?? "" }); + + const body = nws(url); + if (body === undefined) return new Response("nope", { status: 503 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +}) as unknown as typeof globalThis.fetch; + +after(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + calls = []; + upstreamIsUp = true; + upstreamIsGarbled = false; +}); + +function appWith(env: Record) { + const config = loadConfig(env); + config.logLevel = "silent"; + return buildApp(config); +} + +function observations(): string[] { + return calls.filter((call) => call.url.includes("/observations/")).map((call) => call.url); +} + +describe("a configured weather source", () => { + it("fetches the region centre and never the coordinate the caller sent", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + // Berkeley. Inside the Bay Area region, and not a point this deployment + // serves — so it selects San Francisco and San Francisco is what gets asked. + const res = await app.inject({ + method: "GET", + url: "/api/v1/weather?lat=37.8715&lng=-122.2730", + }); + assert.equal(res.statusCode, 200); + + assert.ok( + calls.every((call) => !call.url.includes("37.8715")), + `the caller's coordinate reached the upstream: ${calls.map((c) => c.url).join(" ")}`, + ); + assert.ok(calls.some((call) => call.url.endsWith("/points/37.7749,-122.4194"))); + assert.deepEqual(res.json().location, { lat: 37.7749, lng: -122.4194 }); + }); + + it("holds one observation per city rather than one per box", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + const sf = ( + await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }) + ).json(); + const socal = ( + await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" }) + ).json(); + + // The stub encodes the latitude in the temperature, so two different + // numbers here means two different stations were actually consulted. + assert.equal(sf.temperatureC, 37.7749); + assert.equal(socal.temperatureC, 33.82); + assert.equal(observations().length, 2); + assert.equal(sf.source, "nws"); + assert.equal(sf.synthetic, false); + }); + + it("asks once per region per TTL however many callers turn up", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + const ask = () => app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }); + // Concurrent, then sequential: the first is the single-flight collapse, the + // second is the TTL. Both used to be one fetch each and only one of them was. + await Promise.all([ask(), ask(), ask(), ask()]); + await ask(); + await ask(); + + assert.equal(observations().length, 1); + }); + + it("keeps the cities apart under load, not merely on the first request", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + const bodies = await Promise.all( + ["sf", "socal", "sf", "socal", "sf"].map(async (city) => + (await app.inject({ method: "GET", url: `/api/v1/weather?city=${city}` })).json(), + ), + ); + assert.deepEqual( + bodies.map((body) => body.temperatureC), + [37.7749, 33.82, 37.7749, 33.82, 37.7749], + ); + assert.equal(observations().length, 2); + }); + + /** + * A source that is *up* and answering in a shape this build cannot read is a + * different failure from one that is down, and it used to be a much worse + * one: the parser threw, `upstream.ts` never reached its clock stamp, and the + * TTL — the only thing standing between a public-good API and one outbound + * request per inbound request — stopped existing. `current()` also stopped + * being the thing its own header calls it, which is a function that never + * throws. + */ + it("treats a body it cannot parse as a source that did not answer", async () => { + upstreamIsGarbled = true; + const app = appWith(nwsEnv); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }); + assert.equal(res.statusCode, 200); + // The clear day, which is what "nobody answered" has always meant here. + assert.equal(res.json().synthetic, true); + }); + + it("keeps the TTL when the body is garbled, not just when the socket dies", async () => { + upstreamIsGarbled = true; + const app = appWith(nwsEnv); + after(() => app.close()); + + for (let i = 0; i < 5; i++) { + await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }); + } + assert.equal(observations().length, 1); + }); + + it("identifies the operator to a source that requires it", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + await app.inject({ method: "GET", url: "/api/v1/weather" }); + assert.ok(calls.length > 0); + for (const call of calls) assert.match(call.userAgent, new RegExp(CONTACT)); + }); + + it("serves the last good observation when the upstream goes away", async () => { + // TTL 0 makes every request a refetch, which is what makes the failure + // reachable in a test without waiting ten minutes for one. + const app = appWith({ ...nwsEnv, TERA_WEATHER_TTL: "0" }); + after(() => app.close()); + + const first = ( + await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }) + ).json(); + assert.equal(first.temperatureC, 37.7749); + + upstreamIsUp = false; + const second = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }); + assert.equal(second.statusCode, 200); + const body = second.json(); + // Not a 503, not a clear day: the observation from a minute ago, which is + // the only answer that keeps the sky looking like the sky. + assert.equal(body.temperatureC, 37.7749); + assert.equal(body.synthetic, false); + }); + + it("falls back to a clear day for a city that has never answered", async () => { + upstreamIsUp = false; + const app = appWith(nwsEnv); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.synthetic, true); + assert.equal(body.condition, "clear"); + assert.deepEqual(body.location, { lat: 33.82, lng: -118.05 }); + }); + + it("retries a dead source on the TTL, not on every request", async () => { + upstreamIsUp = false; + const app = appWith(nwsEnv); + after(() => app.close()); + + for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/weather" }); + // One attempt, one failure, one clock stamp. Somebody else's outage must not + // turn into this box's outbound flood. + assert.equal(calls.length, 1); + }); + + it("makes no outbound request at all until somebody asks", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + await app.inject({ method: "GET", url: "/api/v1/health" }); + assert.deepEqual(calls, []); + }); + + it("never calls anybody when the source is off", async () => { + const app = appWith({}); + after(() => app.close()); + + await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" }); + await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" }); + assert.deepEqual(calls, []); + }); + + it("refuses the request before it would have fetched anything", async () => { + const app = appWith(nwsEnv); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/weather?lat=51.5072&lng=-0.1276", + }); + assert.equal(res.statusCode, 400); + // The point of the allowlist: a refused request costs the upstream nothing. + assert.deepEqual(calls, []); + }); +}); diff --git a/server/src/upstream.ts b/server/src/upstream.ts new file mode 100644 index 0000000..5cd415b --- /dev/null +++ b/server/src/upstream.ts @@ -0,0 +1,133 @@ +/** + * A per-key TTL cache in front of something that is allowed to fail. + * + * Weather and flights had the same fifteen lines each, and once both of them + * became *per region* they would have had the same fifteen lines with the same + * `Map` bolted on. Three rules, and they are the three the weather service + * already stated: + * + * 1. **Nothing is fetched until somebody asks.** A box nobody visits makes no + * outbound requests at all, which is what keeps a public-good API's fair-use + * policy satisfiable by a deployment that is mostly idle. + * 2. **A dead upstream serves the last good answer**, per key, and only reports + * nothing when it has never once answered for that key. Ten-minute-old + * weather beats no weather by a mile and beats a 503 by more. + * 3. **A failed fetch stamps the clock too**, and a fetch that *throws* is a + * fetch that failed. A source that is down — or that has started answering + * in a shape this build cannot read — is retried on the same cadence as one + * that is up, not on every request, which is the bug that turns somebody + * else's outage into your outbound flood. See `refresh`. + * + * Concurrent misses on one key collapse into one upstream request. Misses on + * *different* keys do not, deliberately: they are different places and the + * caller's rate budget is worked out per key in `flights/index.ts`. + * + * ### On the size of the map + * + * Keys are region ids, and the region set comes from the environment + * (`regions.ts`), so the number of entries is bounded by the operator's config + * and cannot be grown by anybody sending requests. That invariant lives in the + * routes, which resolve a query to a configured region *before* anything here is + * touched; this module trusts it and does not re-check it. Key this on anything + * a caller can choose and it becomes an unbounded map. + */ + +export interface UpstreamLog { + warn(msg: string): void; +} + +export interface Upstream { + /** + * The freshest value for `key`, or `null` if nothing has ever answered for it. + * + * `fetchFresh` is passed per call rather than at construction so the caller + * can close over the region it just resolved instead of keeping a second + * lookup table. It must be the same function of the same key every time, which + * is trivially true for both callers here — the key *is* the region. + */ + get(key: string, fetchFresh: () => Promise): Promise; +} + +interface Entry { + value: T | null; + /** Epoch ms of the last completed attempt, successful or not. */ + fetchedAt: number; + inFlight: Promise | null; +} + +export interface UpstreamOptions { + /** Prefix for the one warning line this ever logs, e.g. `weather:nws`. */ + label: string; + ttlSeconds: number; + log: UpstreamLog; +} + +export function createUpstream(opts: UpstreamOptions): Upstream { + const ttlMs = Math.max(0, opts.ttlSeconds) * 1000; + const entries = new Map>(); + + /** + * One attempt, and it cannot fail in a way the caller has to know about. + * + * The `try` is rule 3, and the `finally` is the whole of it. `fetchFresh` is + * supposed to return `null` for every failure — `http.ts` does exactly that — + * but "supposed to" is not a guarantee, and one upstream that answers 200 + * with a field of the wrong type is enough: `adsb.ts` iterating a non-array + * `ac`, or `nws.ts` iterating a `cloudLayers` that came back as a number, + * throws out of here. Before this, that throw skipped the clock stamp *and* + * propagated to the route, so the TTL — the only rate limit on outbound calls + * — collapsed to one upstream request per inbound request and the caller got + * a 500. Five requests to `/api/v1/flights` produced five fetches at + * adsb.lol, from the operator's address, and five 500s; that is the flood + * rule 3 exists to prevent, delivered by the failure mode it exists for. + * + * So a throw is made indistinguishable from the `null` it should have been: + * clock stamped, last good value kept, route answers 200 with whatever is in + * hand. The stack goes in the log line, because a source whose *shape* + * changed is a different problem from a source that is down and the operator + * needs to be able to tell them apart. + */ + async function refresh(key: string, entry: Entry, fetchFresh: () => Promise) { + let fresh: T | null = null; + let threw: unknown = null; + try { + fresh = await fetchFresh(); + } catch (err) { + threw = err; + } finally { + entry.fetchedAt = Date.now(); + } + if (threw === null && fresh !== null) { + entry.value = fresh; + return; + } + const why = + threw === null + ? "did not answer" + : `answered with something this build cannot read (${threw instanceof Error ? threw.message : String(threw)})`; + opts.log.warn( + `${opts.label}: ${key} ${why}; serving ` + + `${entry.value === null ? "the fallback" : "the last good answer"}`, + ); + } + + return { + async get(key: string, fetchFresh: () => Promise): Promise { + let entry = entries.get(key); + if (entry === undefined) { + entry = { value: null, fetchedAt: 0, inFlight: null }; + entries.set(key, entry); + } + + if (Date.now() - entry.fetchedAt > ttlMs) { + const pending = entry; + pending.inFlight ??= refresh(key, pending, fetchFresh).finally(() => { + pending.inFlight = null; + }); + await pending.inFlight; + } + + return entry.value; + }, + }; +} diff --git a/server/src/weather/index.ts b/server/src/weather/index.ts index ad4e6ea..9ef3d16 100644 --- a/server/src/weather/index.ts +++ b/server/src/weather/index.ts @@ -1,26 +1,38 @@ /** - * Which source answers, how often it is asked, and what happens when it does not. + * Which source answers, for which place, how often it is asked, and what happens + * when it does not. * - * Three rules, in order of how much trouble getting them wrong causes: + * Four rules, in order of how much trouble getting them wrong causes: * * 1. **This never throws.** `current()` always resolves to a `WeatherBody`. - * 2. **A dead upstream serves the last good observation**, and only falls back - * to the clear day if there has never been one. Ten-minute-old weather is - * better than no weather and much better than a 503. + * 2. **A dead upstream serves the last good observation** for that place, and + * only falls back to the clear day if there has never been one. Ten-minute-old + * weather is better than no weather and much better than a 503. * 3. **Nothing is fetched until somebody asks.** A box nobody visits makes no * outbound requests, which matters when the source is a public good with a * rate limit and a fair-use policy. + * 4. **One cache entry per region, and the regions come from the environment.** + * San Francisco's fog and Los Angeles's sun are two different observations + * and the old single-origin cache could only hold one of them. The keys are + * region ids and nothing a caller sends becomes one — `routes/weather.ts` + * resolves the query to a configured region first, which is what bounds both + * this map and the number of stations `nws.ts` will ever look up. + * + * Rules 1–3 live in `upstream.ts` now, because flights wanted exactly the same + * three. */ import type { Config } from "../config.ts"; +import type { Region } from "../regions.ts"; import type { WeatherBody } from "../../../src/server/wire.ts"; +import { createUpstream } from "../upstream.ts"; import { fetchMetno } from "./metno.ts"; import { fetchNws } from "./nws.ts"; import { fetchOpenMeteo } from "./openmeteo.ts"; import { clearDay } from "./synthetic.ts"; export interface WeatherService { - current(): Promise; + current(region: Region): Promise; } export interface WeatherLog { @@ -28,46 +40,28 @@ export interface WeatherLog { } export function createWeatherService(config: Config, log: WeatherLog): WeatherService { - const { lat, lng } = config.origin; const { source, contact, ttlSeconds } = config.weather; + const upstream = createUpstream({ label: `weather:${source}`, ttlSeconds, log }); - let cached: WeatherBody | null = null; - let fetchedAt = 0; - let inFlight: Promise | null = null; - - async function refresh(): Promise { - const fresh = - source === "nws" - ? await fetchNws(lat, lng, contact) - : source === "metno" - ? await fetchMetno(lat, lng, contact) - : source === "openmeteo" - ? await fetchOpenMeteo(lat, lng) - : null; - - // Stamp the clock either way. A source that is down should be retried on the - // same cadence as one that is up, not hammered once per request. - fetchedAt = Date.now(); - if (fresh !== null) { - cached = fresh; - return; - } - log.warn(`weather: ${source} did not answer; serving ${cached === null ? "a synthetic clear day" : "the last observation"}`); + function fetchFor(region: Region): Promise { + // The coordinate that goes upstream is the *region centre*, never the one + // the caller sent. See the top of `regions.ts` for why that distinction is + // the whole abuse story on this endpoint. + const { lat, lng } = region; + return source === "nws" + ? fetchNws(lat, lng, contact) + : source === "metno" + ? fetchMetno(lat, lng, contact) + : source === "openmeteo" + ? fetchOpenMeteo(lat, lng) + : Promise.resolve(null); } return { - async current(): Promise { - if (source === "none") return clearDay(lat, lng); - - const stale = Date.now() - fetchedAt > ttlSeconds * 1000; - if (stale) { - // Collapse concurrent misses into one upstream request. - inFlight ??= refresh().finally(() => { - inFlight = null; - }); - await inFlight; - } - return cached ?? clearDay(lat, lng); + async current(region: Region): Promise { + if (source === "none") return clearDay(region.lat, region.lng); + const body = await upstream.get(region.id, () => fetchFor(region)); + return body ?? clearDay(region.lat, region.lng); }, }; } diff --git a/server/src/weather/nws.ts b/server/src/weather/nws.ts index 3993e3c..a89be1a 100644 --- a/server/src/weather/nws.ts +++ b/server/src/weather/nws.ts @@ -10,6 +10,13 @@ * Getting an observation takes three hops — point to station list, station list * to nearest station, station to latest observation — so the first two are * resolved once per process and kept. Stations do not move. + * + * That station cache is keyed on the coordinate, which means its size is exactly + * the number of places this box will answer for. It stays bounded because the + * only coordinates that reach here are region centres from `regions.ts`: a + * caller cannot name a point, so a caller cannot grow this map, and the three + * hops are paid once per region for the life of the process rather than once per + * curious request. */ import { getJson, userAgent } from "../http.ts"; diff --git a/src/access.ts b/src/access.ts index 2ffedc6..31ea94b 100644 --- a/src/access.ts +++ b/src/access.ts @@ -86,12 +86,45 @@ export interface Capabilities { debug: boolean; } +/** + * Which of the three feeds this deployment has actually wired. + * + * A capability says what a *visitor* may have; this says what the *server* has, + * and the app needs both before it opens a socket. `can.liveData` is true for + * every member of every deployment, including the overwhelming majority that + * have `weather: "none"` — so gating on the capability alone starts a + * ten-minute weather poll against a box that will answer 404 to all of it, + * forever, on every tab that is open. + * + * Each field is `true` when `/health` named a source other than `"none"`, which + * is deliberately coarser than the string. The app does not care whether the + * weather comes from NWS or met.no; it cares whether asking is pointless. + * `flights: "sim"` counts as wired, because the server's synchronised plan is + * worth fetching even though it is not observed — `TrafficSource.live()` is the + * thing that knows the difference, and it says `false` for it. + */ +export interface Feeds { + weather: boolean; + flights: boolean; + markers: boolean; +} + export interface Access { tier: Tier; subject: string | null; /** Where to send someone who is not signed in. `null` means this deployment has no door. */ signInUrl: string | null; can: Capabilities; + /** + * What `/health` said is wired, or `null` when nothing answered — which is + * the zero-config case, and means every feed is the bundled sample. + * + * It rides along here rather than being fetched again by whoever wants it + * because this module has already paid for the round trip: `/health` is the + * first thing boot asks for, and a second identical GET a moment later to + * read a different field of the same body is a request nobody needs to make. + */ + feeds: Feeds | null; } /** @@ -147,6 +180,7 @@ export function capabilitiesFor(tier: Tier): Capabilities { export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise { const health = await getJson<{ auth?: { mode?: unknown; entryUrl?: unknown }; + sources?: unknown; }>(fetcher, "/health"); // Something is mounted at `/api/v1` and it is unwell. That is not the same @@ -165,10 +199,11 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< const body = health.body; const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none"; const entryUrl = entryHref(body.auth?.entryUrl); + const feeds = feedsFrom(body.sources); // A box with auth switched off is a self-host that chose to stay open. Same // deal as no API at all, and for the same reason it is `member` and not `god`. - if (mode === "none") return access("member", null, null); + if (mode === "none") return access("member", null, null, feeds); const fetched = await getJson<{ authenticated?: unknown; @@ -211,12 +246,32 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< */ const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null); - if (!authenticated) return access("anon", null, signInUrl); - return access(admin ? "god" : "member", subject, signInUrl); + if (!authenticated) return access("anon", null, signInUrl, feeds); + return access(admin ? "god" : "member", subject, signInUrl, feeds); } -function access(tier: Tier, subject: string | null, signInUrl: string | null): Access { - return { tier, subject, signInUrl, can: capabilitiesFor(tier) }; +function access( + tier: Tier, + subject: string | null, + signInUrl: string | null, + feeds: Feeds | null = null, +): Access { + return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds }; +} + +/** + * `/health`'s `sources` block, read as three yes/no answers. + * + * Defensively, like `admin` above and for the same reason: this field is newer + * than some servers this client will meet, and a missing one has to fall the + * safe way. Here "safe" is `false` — no feed, no request — because the bundled + * sample set is a working map and a poll against a server that never heard of + * the route is not. + */ +function feedsFrom(raw: unknown): Feeds { + const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none"; + return { weather: wired("weather"), flights: wired("flights"), markers: wired("markers") }; } /** @@ -291,6 +346,18 @@ async function getJson(fetcher: typeof fetch, path: string): Promise { attribution: string[]; } -export interface WeatherFeed extends Feed { +/** + * The sky, or an admission that nobody knows what the sky is doing. + * + * `value` is nullable and that null is load-bearing rather than lazy. It is + * exactly `Environment.weather` in `atmosphere.ts`, where `null` means "nobody + * was asked" and lets the local climatology run, and a `WeatherObservation` + * means somebody looked — which `apply` then treats as authority over the + * model. So the type matches the argument it is destined for, the caller can + * hand `feed.value` straight to `observe()`, and there is no shape in which a + * failed fetch can be mistaken for a report of a clear sky. See + * `noObservation` for what that mistake actually did to the fog. + */ +export interface WeatherFeed extends Feed { + /** + * ISO-8601 observation time, or `null` when nobody observed anything. + * + * The *observation* time and not the fetch time, which is the field's whole + * value: the server serves from a ten-minute cache, so a body that arrived a + * second ago can already describe a sky from ten minutes ago, and the only + * way to know how old the weather is is to be told. + */ + observedAt: string | null; attribution: string[]; } +/** + * A live weather feed for one place, polled until somebody stops it. + * + * A watch and not a promise because weather has no natural moment: the map is + * open for an hour, the marine layer arrives at some point during it, and a + * value fetched once at boot is a photograph of a sky that has since changed. + * + * `stop()` is not optional housekeeping. Switching city while a poll is in + * flight is the ordinary case, not the rare one — the request takes a second + * and the button takes a moment — and an answer for the old city landing in the + * new city's rig is San Francisco's fog over Long Beach. So a stopped watch + * aborts what it has in the air and refuses to publish anything that arrives + * anyway. + */ +export interface WeatherWatch { + /** The latest feed. Nobody-was-asked, and not live, until an answer lands. */ + current(): WeatherFeed; + /** Ask now rather than at the next tick. Ignored while a request is in flight. */ + refresh(): void; + /** Stop polling, abort anything in flight, and drop any late answer. */ + stop(): void; +} + export interface TeraClient { /** What the deployment turned out to be, or `null` if there is no server. */ health(): Promise; markers(): Promise; - weather(): Promise; /** - * The traffic source, built once. It fetches on its own schedule and never - * blocks the render loop; see `HttpFlights`. + * The sky over one place, once. + * + * `at` is required. There is no sensible default for it — see the note at the + * top of this file — and a default would have been the bug. */ - flights(): FlightSource; + weather(at: Place, options?: { signal?: AbortSignal }): Promise; + /** + * The sky over one place, kept up to date. `onFeed` fires once per settled + * poll, including the ones that change nothing. + */ + watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch; + /** + * The traffic source for one region. It fetches on its own schedule and never + * blocks the render loop; see `HttpFlights`. + * + * `fallbackRoutes` is what the simulator flies while the network has not + * answered, and defaults to something generated inside the region rather than + * to this repo's sample set — the sample set is over San Francisco, and a + * default that is only correct for one city is the failure this signature was + * changed to prevent. Callers with hand-authored corridors for the city + * should pass them; `sampleRoutesFor` in `sample.ts` has them. + */ + flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource; /** * One office pack. `null` for anything the server will not serve — including * a private one, which answers 404 rather than 403 so the endpoint cannot be @@ -123,6 +216,23 @@ export interface TeraClient { office(id: string): Promise; } +/** + * One GET's worth of options: what to put in the query string, and a way for + * the caller to give up on it. + * + * The signal is the caller's, and is in addition to this module's own timeout + * rather than instead of it. They cancel different things: the timeout is about + * a server that is slow, and the signal is about an answer that has stopped + * being wanted — a city switched, a page unloading — which can happen well + * inside a healthy response time. + */ +interface GetOptions { + query?: Record; + signal?: AbortSignal; +} + +type Get = (path: string, options?: GetOptions) => Promise; + export function createTeraClient(options: TeraApiOptions = {}): TeraClient { const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, ""); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -131,17 +241,25 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { /** * One GET, and `null` for every way it can go wrong. * - * Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static - * host serving `index.html` with a 200 and an HTML content type — the caller's - * response to all of them is the same, and a taxonomy of failures nobody - * branches on is a taxonomy nobody maintains. + * Deliberately undiscriminating. A 404, a timeout, a CORS refusal, an abort, a + * static host serving `index.html` with a 200 and an HTML content type — the + * caller's response to all of them is the same, and a taxonomy of failures + * nobody branches on is a taxonomy nobody maintains. + * + * Note that an abort therefore looks exactly like a failure. Everything that + * aborts on purpose here checks its own cancelled flag before doing anything + * with the `null`, because treating "you asked me to stop" as "the server is + * down" would have a city switch trip the back-off ladder. */ - async function get(path: string): Promise { + const get: Get = async (path: string, opts: GetOptions = {}): Promise => { if (!doFetch) return null; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const abort = () => controller.abort(); + opts.signal?.addEventListener("abort", abort); + if (opts.signal?.aborted) controller.abort(); + const timer = setTimeout(abort, timeoutMs); try { - const res = await doFetch(`${base}${path}`, { + const res = await doFetch(`${base}${path}${queryString(opts.query)}`, { signal: controller.signal, headers: { accept: "application/json" }, }); @@ -156,10 +274,9 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { return null; } finally { clearTimeout(timer); + opts.signal?.removeEventListener("abort", abort); } - } - - let flightSource: FlightSource | null = null; + }; return { health: () => get("/health"), @@ -189,43 +306,318 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { }; }, - async weather(): Promise { - const body = await get("/weather"); - if (!body) return { value: CLEAR_DAY, live: false, attribution: [] }; - // `WeatherBody` is structurally a `WeatherObservation` plus fields no - // renderer reads, which `atmosphere.ts` says in as many words. The extra - // fields ride along harmlessly and the engine never sees them. - return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] }; + async weather(at: Place, opts: { signal?: AbortSignal } = {}): Promise { + const body = await get("/weather", { + query: whereQuery(at), + ...(opts.signal ? { signal: opts.signal } : {}), + }); + return weatherFeed(at, body); }, - flights(): FlightSource { - flightSource ??= new HttpFlights(get, SAMPLE_ROUTES); - return flightSource; + watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch { + return watchWeather(get, at, onFeed); + }, + + flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource { + return new HttpFlights(get, region, fallbackRoutes ?? syntheticRoutes(region)); }, office: (id) => get(`/offices/${encodeURIComponent(id)}`), }; } +// ---- Weather -------------------------------------------------------------- + /** - * The clear day a zero-config box serves, restated in the browser. + * How often a watch asks, when the last answer was a good one. * - * The server does this too — a weather source configured without what it needs - * is demoted rather than fatal, and it answers `synthetic: true` forever - * (CONTRACT.md §5.1). This is the same answer for the case where there is no - * server at all. Note what it does *not* do: `visibilityKm` stays null, which - * `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San - * Francisco's marine layer still runs off its own climatology instead of being - * overruled by a fact nobody observed. + * Ten minutes, which is the server's own `TERA_WEATHER_TTL` default and not a + * coincidence: asking faster spends a request to be handed the same cached body + * back. The upstreams behind that cache agree about the order of magnitude — + * NWS publishes observations hourly, MET Norway's terms ask callers to respect + * the `Expires` header rather than poll on their own clock, and Open-Meteo's + * current block moves quarter-hourly. Nothing that arrives faster than ten + * minutes is new information. + * + * The other end of the argument is what the map does with it. Cloud cover moves + * a directional light's intensity and a fog distance, both of which are ramped + * over seconds by `atmosphere.ts` anyway, so a late arrival looks like weather + * changing and never like a jump. This is a map, not a dashboard: the marine + * layer arriving three minutes after it really did is not an error anybody can + * detect, and a poll a minute for eight hours is 480 requests to find that out. */ -const CLEAR_DAY: WeatherObservation = { - cloudCover: 0.1, - precipitation: 0, - visibilityKm: null, - windKph: null, - windDirDeg: null, - condition: "clear", -}; +const WEATHER_INTERVAL_MS = 10 * 60_000; + +/** + * The ceiling on the back-off ladder a failing watch climbs. + * + * Backing off is the *normal* path here rather than an outage measure. The + * commonest deployment of this bundle is a static host with no API at all, and + * on one of those every poll fails forever — so the delay doubles from the ten + * minute interval up to an hour and stays there, and a tab left open overnight + * makes a dozen requests instead of fifty. The delay never shortens on failure, + * which is the retry storm this exists to not be. + */ +const WEATHER_MAX_INTERVAL_MS = 60 * 60_000; + +/** + * How far a reported observation may be from the place that was asked about + * before it is somebody else's weather. + * + * A hundred and fifty kilometres, and both bounds on that number are real. It + * has to be large: a station anywhere on the Bay Area board is a perfectly good + * answer for the Bay Area, and the far corner of that board is a hundred and + * seventeen kilometres from the point this client asks about, so a tight radius + * would throw away correct observations. It has to be small: the two cities in + * this build are five hundred and ninety kilometres apart, and the failure being + * defended against is a server holding one `TERA_ORIGIN_LAT/LNG` answering every + * request with San Francisco's fog while somebody looks at Long Beach. Anything + * from about a hundred and twenty to about three hundred separates those two + * cases cleanly. + * + * This is what makes the client safe against a server that ignores the location + * it was given — which is every server built before this parameter existed. + * `WeatherBody.location` says where the observation is actually from, so the + * check is on the answer rather than on a promise about the question. + */ +const WEATHER_RELEVANCE_KM = 150; + +/** Kilometres in a nautical mile, for the one place the two units meet. */ +const KM_PER_NM = 1.852; + +/** + * How old an observation may be before the map stops calling it the weather. + * + * An hour, measured from `observedAt` rather than from when the body arrived, + * because a body that has just arrived can already be ten minutes old — see + * `WeatherFeed.observedAt`. Under that hour a failed poll holds the last good + * observation instead of reverting: a deployment that has been showing real + * weather all afternoon and drops one request should keep showing it, which is + * the same rule `HttpFlights` follows for traffic and for the same reason. + * + * The hour itself is the marine layer's. Fog over the western half of San + * Francisco burns back to the coast in about that on a summer morning, so an + * hour-old sky presented as the current one is precisely the lie the `live` + * flag was added to prevent — and past that point, handing the sky back to the + * local model is the more honest picture. + */ +const WEATHER_STALE_MS = 60 * 60_000; + +/** + * What every failure resolves to: nobody was asked. + * + * `null` and emphatically **not** a clear day, which is what this returned + * first and what the fallback in an earlier draft of this file was. The two + * are different to `atmosphere.ts` in a way that is easy to miss and very + * visible on screen. A `WeatherObservation` saying `condition: "clear"` with no + * visibility reported is an *observation of a clear sky*, and `apply` treats a + * reported clear sky as authoritative: `observed === 0` suppresses the modelled + * obscuration outright, on the entirely correct principle that somebody who + * looked out of the window beats a climatology. Hand it a clear day the + * moment the API 404s and San Francisco loses its marine layer — permanently, + * on a zero-config box, which is the commonest way this bundle is run and the + * one configuration where the local model is all there is. + * + * `null` means nobody looked, `apply` runs the marine layer off the season and + * the hour, and the fog comes in over the Sunset on a June morning with no + * server involved at all. + */ +function noObservation(): WeatherFeed { + return { value: null, live: false, observedAt: null, attribution: [] }; +} + +/** + * One weather body, judged. + * + * Four outcomes and only one of them is an observation. No body at all is + * nobody-was-asked. A body about somewhere else is *also* nobody-was-asked, + * deliberately: rendering a real observation of a place the viewer is not + * looking at is worse than rendering none, because it is wrong and it is + * convincing. A `synthetic` body is the server saying in as many words that it + * has no source — its numbers were invented by `weather/synthetic.ts` and are + * not evidence of anything, so they are dropped for the same reason, and the + * local model gets to run instead of being overruled by a fact nobody observed. + * What is left is an observation, and it is the only thing that is live. + */ +function weatherFeed(at: Place, body: WeatherBody | null): WeatherFeed { + if (!body) return noObservation(); + if (body.synthetic) return noObservation(); + if (elsewhere(at, body)) return noObservation(); + // `WeatherBody` is structurally a `WeatherObservation` plus fields no + // renderer reads, which `atmosphere.ts` says in as many words. The extra + // fields ride along harmlessly and the engine never sees them. + return { + value: body, + live: true, + observedAt: body.observedAt ?? null, + attribution: body.attribution ?? [], + }; +} + +/** Whether a body describes a different part of the world from the one asked about. */ +function elsewhere(at: Place, body: WeatherBody): boolean { + const where = body.location; + // A body with no location is one this client cannot place, and an + // unplaceable observation is exactly as useful as a wrong one. + if (!where || typeof where.lat !== "number" || typeof where.lng !== "number") return true; + return distanceNm(at, where) * KM_PER_NM > WEATHER_RELEVANCE_KM; +} + +/** + * Poll one place's weather until told to stop. + * + * Free of any timer the caller has to own. `atmosphere.apply` is pure and the + * scene relights from whatever it is handed, so the honest shape is a callback + * on new information rather than something the render loop has to remember to + * ask. + */ +function watchWeather(get: Get, at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch { + let feed = noObservation(); + let receivedAt = 0; + /** + * When a poll last *settled*, successfully or not — which is a different fact + * from when an answer last arrived, and the one the wake-up check needs. + * + * `receivedAt` is written only on the success path, so on a deployment whose + * weather source is configured and failing it stays `0` forever and + * `Date.now() - 0` clears every threshold there is. `onVisible` was gated on + * it, so every alt-tab back to the map cancelled whichever rung of the + * back-off ladder was pending and fired an immediate request: twenty + * alt-tabs, twenty requests, which is precisely what + * `WEATHER_MAX_INTERVAL_MS` exists not to do. `server/src/upstream.ts` states + * the same rule from the other side and calls a clock that only a success + * stamps the bug that turns somebody else's outage into your outbound flood. + * + * `receivedAt` stays, because `tooOld()` is genuinely asking "how old is what + * I am showing" and a failed poll does not make it any fresher. + */ + let attemptedAt = 0; + /** The delay the last settled poll asked for, so the wake-up can respect it. */ + let nextDelayMs = 0; + let failures = 0; + let stopped = false; + let timer: ReturnType | null = null; + let inFlight: AbortController | null = null; + + /** + * Whether what is in hand is still worth showing. + * + * Prefers the observation time on the body and falls back to when it arrived, + * which is the answer for a source that did not stamp one. + */ + function tooOld(): boolean { + const stamped = feed.observedAt === null ? NaN : Date.parse(feed.observedAt); + const since = Number.isNaN(stamped) ? receivedAt : stamped; + return Date.now() - since > WEATHER_STALE_MS; + } + + function schedule(delayMs: number) { + if (stopped) return; + if (timer !== null) clearTimeout(timer); + nextDelayMs = delayMs; + timer = setTimeout(() => void tick(), delayMs); + } + + function publish(next: WeatherFeed) { + // A repeated fallback is not news. Every real observation is a fresh object + // so it always gets through; two of nothing in a row are both `null`, and + // publishing the second only asks the scene to relight itself identically. + if (next.value === feed.value && next.live === feed.live) return; + feed = next; + onFeed(next); + } + + async function tick(): Promise { + timer = null; + if (stopped) return; + // Nothing reaches this with a request already out, but a watch that stalled + // would stay stalled until the page reloaded, and that is too quiet a + // failure to leave to the reasoning being right. + if (inFlight) { + schedule(WEATHER_INTERVAL_MS); + return; + } + inFlight = new AbortController(); + const body = await get("/weather", { + query: whereQuery(at), + signal: inFlight.signal, + }); + inFlight = null; + // The watch was stopped while this was in the air. Whatever came back is + // the old city's sky and must not be published — `stop()` has already + // aborted the request and this is the belt to that pair of braces. It is + // also why a cancelled request must not count as a failure below. + if (stopped) return; + // Every settled poll, either branch. Deliberately not set for the abort + // above: "you asked me to stop" is not an attempt that tells us anything + // about the server. + attemptedAt = Date.now(); + + if (!body) { + failures += 1; + // Hold what is in hand until it is too old to be honest about. + if (feed.live && tooOld()) publish(noObservation()); + schedule(Math.min(WEATHER_INTERVAL_MS * 2 ** (failures - 1), WEATHER_MAX_INTERVAL_MS)); + return; + } + + failures = 0; + receivedAt = Date.now(); + // `weatherFeed` refuses this body too; the branch is here for the schedule. + // A box answering about another city will answer that way until somebody + // redeploys it, which is not worth a request every ten minutes — whereas a + // `synthetic` body, which is also refused, comes from a source that may + // come back, and is worth asking about again on the ordinary cadence. + if (elsewhere(at, body)) { + publish(noObservation()); + schedule(ELSEWHERE_SECONDS * 1000); + return; + } + publish(weatherFeed(at, body)); + schedule(WEATHER_INTERVAL_MS); + } + + /** + * Ask again on the way back into a tab that has been away. + * + * A laptop shut for six hours wakes showing the sky from before lunch, and + * waiting out the rest of a ten-minute interval in front of it is a long time + * to look at stale fog. Browsers throttle timers in hidden tabs and may not + * have fired ours at all, so the wake-up is the event worth listening for + * rather than a shorter interval that would cost a request every time. + */ + const onVisible = () => { + if (document.visibilityState !== "visible") return; + // Against the delay that is actually pending, so a source on the back-off + // ladder is left where it is. On a healthy watch that delay is + // `WEATHER_INTERVAL_MS` and this behaves exactly as it always did. + if (Date.now() - attemptedAt >= nextDelayMs) refresh(); + }; + const hasDocument = typeof document !== "undefined"; + if (hasDocument) document.addEventListener("visibilitychange", onVisible); + + function refresh() { + if (stopped || inFlight) return; + schedule(0); + } + + schedule(0); + + return { + current: () => feed, + refresh, + stop() { + stopped = true; + if (timer !== null) clearTimeout(timer); + timer = null; + inFlight?.abort(); + inFlight = null; + if (hasDocument) document.removeEventListener("visibilitychange", onVisible); + }, + }; +} + +// ---- Markers -------------------------------------------------------------- function sampleMarkerFeed(): MarkerFeed { return { @@ -238,8 +630,60 @@ function sampleMarkerFeed(): MarkerFeed { }; } +// ---- Asking about a place ------------------------------------------------- + +/** + * The location half of a query, rounded to about a kilometre. + * + * Rounded for the shared cache, which is the whole reason the precision is + * thrown away. `/weather` and `/flights` are served with a public cache header + * and the query string is part of the cache key, so two viewers of the same + * city have to produce byte-identical URLs or the cache is a per-viewer cache + * and the upstream gets hit once per person. A city centre is a constant in + * this build and would round identically anyway; a caller that ever passes a + * camera position instead gets the same protection for free, along with not + * having put anybody's exact position in an access log. + */ +function whereQuery(at: Place): Record { + return { lat: at.lat.toFixed(2), lng: at.lng.toFixed(2) }; +} + +function queryString(query: Record | undefined): string { + if (!query) return ""; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) params.set(key, String(value)); + const encoded = params.toString(); + return encoded === "" ? "" : `?${encoded}`; +} + // ---- Traffic -------------------------------------------------------------- +/** + * A `FlightSource` that also knows whether what it is handing over is real. + * + * The extra method is here rather than on `FlightSource` in `engine/types.ts` + * because the engine has no business with provenance: it draws darts at + * coordinates, and whether the coordinates were observed is a question about + * the deployment. Only the interface layer asks it, so only this layer declares + * it. + */ +export interface TrafficSource extends FlightSource { + /** True while the aircraft `poll()` returns are observed positions for this region. */ + live(): boolean; + /** + * Credit lines for whatever is currently being drawn, and empty when nothing + * on screen came from anybody else. + * + * Here for the same reason `live()` is: the engine draws darts and has no + * business with provenance, but a community feed that asks to be named has + * asked the *deployment*, and this is the layer that knows a deployment + * exists. `describeLiveness` says what is live; this says who to thank for it. + */ + attribution(): string[]; + /** Stop fetching and abort anything in flight. Idempotent. */ + dispose(): void; +} + /** * Traffic over HTTP, in whichever of the two shapes the server chose. * @@ -257,10 +701,20 @@ function sampleMarkerFeed(): MarkerFeed { * refetched. * * Until the first response lands, and after any failure, this is the simulator - * over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and - * the invented one is labelled as such in `sample.ts`. + * over whatever routes the caller handed in. An empty sky is a worse answer than + * an invented one, and the invented one is labelled as such in `sample.ts`. + * + * **Everything that arrives is checked against the region before it is drawn.** + * The region goes out on the query and is checked again on the way back, which + * is not belt and braces — a server built before that parameter existed answers + * every caller from its single configured origin, and it answers 200. A plan + * whose routes are all somewhere else, or a snapshot with aircraft in it but + * none of them here, is not this city's sky and is refused in favour of the + * simulator. The failure this prevents is not subtle: San Francisco's traffic + * over the SoCal board projects clean off the world and renders as nothing at + * all, so the map looks broken rather than wrong. */ -class HttpFlights implements FlightSource { +class HttpFlights implements TrafficSource { /** * One second, which is the *evaluation* cadence and not the request cadence. * A plan is arithmetic and wants to be evaluated every frame or close to it; @@ -269,14 +723,18 @@ class HttpFlights implements FlightSource { readonly interval = 1; private readonly fallback: SimulatedFlights; + private mode: "fallback" | "plan" | "live" = "fallback"; private plan: FlightsPlanBody | null = null; private planPhase: number[] = []; - private live: Aircraft[] | null = null; + private aircraft: Aircraft[] = []; + private credits: string[] = []; private nextFetchAt = 0; - private fetching = false; + private inFlight: AbortController | null = null; + private stopped = false; constructor( - private readonly get: (path: string) => Promise, + private readonly get: Get, + private readonly region: SkyRegion, fallbackRoutes: SimRoute[], ) { this.fallback = new SimulatedFlights(fallbackRoutes); @@ -284,17 +742,52 @@ class HttpFlights implements FlightSource { poll(): Aircraft[] { this.refreshIfStale(); - if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now()); - if (this.live) return this.live; + const { mode, plan, planPhase } = this; + if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now()); + if (this.mode === "live") return this.aircraft; return this.fallback.poll(); } + /** + * The server's own simulated plan is not live traffic and does not say it is. + * It is a better simulation than the local one — every viewer agrees about + * where the aircraft are — but nobody observed any of it, and `sources.flights` + * on `/health` calls it `sim` for the same reason. + */ + live(): boolean { + return this.mode === "live"; + } + + /** + * Only while the body they came with is what is on screen. Falling back to + * the simulator drops them, because the simulator's aircraft are this repo's + * invention and crediting adsb.lol for them would be worse than crediting + * nobody. + */ + attribution(): string[] { + return this.mode === "fallback" ? [] : this.credits; + } + + dispose(): void { + this.stopped = true; + this.inFlight?.abort(); + this.inFlight = null; + } + private refreshIfStale(): void { const now = Date.now(); - if (this.fetching || now < this.nextFetchAt) return; - this.fetching = true; - void this.get("/flights") + if (this.stopped || this.inFlight || now < this.nextFetchAt) return; + const controller = new AbortController(); + this.inFlight = controller; + void this.get("/flights", { + query: { ...whereQuery(this.region.center), radiusNm: Math.round(this.region.radiusNm) }, + signal: controller.signal, + }) .then((body) => { + // Disposed while the request was in the air: the city has changed, this + // object is nobody's traffic source any more, and a late answer must + // not restart its clock or write to its state. + if (this.stopped) return; if (!body) { // Hold whatever was already in hand rather than reverting to the // simulator: a deployment that has been showing real traffic for an @@ -303,22 +796,129 @@ class HttpFlights implements FlightSource { this.nextFetchAt = now + RETRY_SECONDS * 1000; return; } - if (body.mode === "plan") { - this.plan = body; - this.planPhase = phasesFor(body); - this.live = null; - } else { - this.live = body.aircraft; - this.plan = null; - } - this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000; + this.nextFetchAt = now + this.adopt(body) * 1000; + }) + /** + * The clock gets set whatever happens, and this is the branch that says + * so for the case nobody plans for. + * + * `get` swallows every network failure already, so the only way here is a + * body that broke `adopt` — which is exactly what used to happen, and + * what it used to do was leave `nextFetchAt` at 0 and become an unhandled + * rejection. A `.finally` without a `.catch` clears `inFlight` and + * restores nothing, so the next `poll()` starts another fetch, and + * `poll()` runs at `interval` seconds: one `/flights` request per second + * per open tab, indefinitely, off one malformed response. + */ + .catch(() => { + if (this.stopped) return; + this.nextFetchAt = now + RETRY_SECONDS * 1000; }) .finally(() => { - this.fetching = false; + if (this.inFlight === controller) this.inFlight = null; }); } + + /** + * Take the body if it is about this region, and say how long to wait next. + * + * **Everything read off the body is checked first**, which `markers()` does a + * few hundred lines up and this did not. `Math.max(1, body.ttlSeconds)` on an + * absent `ttlSeconds` is `NaN`, `nextFetchAt` becomes `NaN`, and + * `now < this.nextFetchAt` is false forever — the poll interval quietly + * becomes the frame rate. `body.aircraft.filter(...)` on an absent array + * throws, which took the same route by a different door. Both were reachable + * from a 200 with valid JSON in it, which is what a server one version behind + * this one sends. + * + * A body that fails these is not this region's traffic and is treated as the + * failure it is: the simulator, and ask again in `RETRY_SECONDS`. + */ + private adopt(body: FlightsBody): number { + const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS; + this.credits = []; + + if (body.mode === "plan") { + if (!Array.isArray(body.routes)) { + this.mode = "fallback"; + this.plan = null; + return RETRY_SECONDS; + } + // Phases are drawn over the *whole* plan and then filtered alongside the + // routes, never over the surviving subset. The seed is what makes two + // browsers agree about where the aircraft are, and it only does that if a + // given route draws the same number wherever it is looked at — filter + // first and a viewer of a two-city plan disagrees with a viewer of the + // one-city plan the same server would serve tomorrow. + const phases = phasesFor(body); + const routes: FlightsPlanBody["routes"] = []; + const planPhase: number[] = []; + body.routes.forEach((route, i) => { + // A leg counts as ours if either end is anywhere near the board — an + // arrival begins a long way outside it, which is most of the point of + // drawing traffic at all. + const mine = + inRegion(this.region, route.from[0], route.from[1], PLAN_SLACK_NM) || + inRegion(this.region, route.to[0], route.to[1], PLAN_SLACK_NM); + if (!mine) return; + routes.push(route); + planPhase.push(phases[i] ?? 0); + }); + if (routes.length === 0) { + this.mode = "fallback"; + this.plan = null; + return ELSEWHERE_SECONDS; + } + // Only the legs that are here, so a server serving one plan for several + // metros does not put the other cities' aircraft off the edge of this one. + this.plan = { ...body, routes }; + this.planPhase = planPhase; + this.mode = "plan"; + return ttl; + } + + if (!Array.isArray(body.aircraft)) { + this.mode = "fallback"; + this.plan = null; + return RETRY_SECONDS; + } + const here = body.aircraft.filter((a) => inRegion(this.region, a.lat, a.lng, LIVE_SLACK_NM)); + // An empty feed and a feed about somewhere else look the same after + // filtering and are not the same thing. Three in the morning over a small + // city really is an empty sky and should be drawn as one; a feed with fifty + // aircraft in it and not one of them within a hundred miles of the board is + // a server pointed at another city, and there the simulator is the honest + // picture. + if (here.length === 0 && body.aircraft.length > 0) { + this.mode = "fallback"; + return ELSEWHERE_SECONDS; + } + this.aircraft = here; + this.plan = null; + this.mode = "live"; + // Only the live body carries credits — `wire.ts` puts `attribution` on + // `FlightsLiveBody` and not on the plan, because the plan is this project's + // own arithmetic and there is nobody to thank for it. Filtered rather than + // trusted for the same reason as everything else in this method. + this.credits = Array.isArray(body.attribution) + ? body.attribution.filter((line): line is string => typeof line === "string") + : []; + return ttl; + } } +/** + * Slack on the region tests, in nautical miles. + * + * Generous on the plan, because a plan is checked once and rejecting it wrongly + * costs the deployment its whole shared sky for fifteen minutes. Tighter on + * live positions, where the test also does duty as the filter that keeps + * aircraft from being drawn off the edge of the board, and where a wrong answer + * costs one dart for one poll. + */ +const PLAN_SLACK_NM = 120; +const LIVE_SLACK_NM = 30; + /** * The per-route phase offsets, from the seed the server sent. * @@ -337,3 +937,53 @@ function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Ai // there so the server can build one without importing three.js. return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0))); } + +// ---- Saying which ----------------------------------------------------------- + +/** + * Which of the three feeds on screen are real, right now. + * + * Three booleans rather than one because they are genuinely independent: the + * markers come from a file the operator wrote, the weather from a government + * API, the traffic from a receiver on somebody's roof, and every combination of + * the three is a deployment that exists. A single flag has to pick one of them + * to be about and then lie about the other two. + */ +export interface Liveness { + /** Markers came from the API rather than from `sample.ts`. */ + markers: boolean; + /** The sky is a real observation, of this city, recent enough to mean it. */ + weather: boolean; + /** The aircraft are observed positions, in this city's region. */ + flights: boolean; +} + +/** + * The corner label, derived from what is actually live. + * + * This used to be one boolean and the boolean was the markers feed, so a + * deployment with a real ADS-B receiver and a real weather station but no + * marker file said nothing at all, and a deployment with a marker file and + * neither of the other two claimed the lot. Both are wrong the same way. The + * label sits in the corner of the whole map, so it is read as a claim about the + * whole map, and the only claim that can be made about the whole map is one + * that is true of all of it. + * + * Hence three cases. Nothing live is **silence**, which is `renderLegend`'s + * existing rule and the right one: a caption that is on screen always is + * furniture nobody reads, and the fabricated-data disclosure has a better home + * on the boot card and in the `?` card, where it is read once and reachable + * forever. Some of it live **names the parts**, because "live data" over + * invented companies is exactly the lie the `live` flag was introduced to + * prevent, and "live weather" over invented companies is not. All three live is + * the only case that earns the unqualified claim. + */ +export function describeLiveness(live: Liveness): string { + const parts: string[] = []; + if (live.weather) parts.push("weather"); + if (live.flights) parts.push("traffic"); + if (live.markers) parts.push("markers"); + if (parts.length === 0) return ""; + if (parts.length === 3) return "live data"; + return `live ${parts.join(" + ")}`; +} diff --git a/src/adapters/sample.ts b/src/adapters/sample.ts index d6a0048..74a8dbe 100644 --- a/src/adapters/sample.ts +++ b/src/adapters/sample.ts @@ -27,8 +27,8 @@ * without data that exercises it. */ -import type { SimRoute } from "../engine/flights.ts"; -import type { Marker, MarkerPalette } from "../engine/types.ts"; +import { regionOf, syntheticRoutes, type SimRoute } from "../engine/flights.ts"; +import type { City, Marker, MarkerPalette } from "../engine/types.ts"; /** * A small pipeline, as colours. @@ -287,7 +287,8 @@ export const SAMPLE_MARKERS: Marker[] = [ ]; /** - * Sample traffic, for when the API is not there to send a flight plan. + * Sample traffic over the Bay Area, for when the API is not there to send a + * flight plan. * * The corridors are roughly the real ones — arrivals down the peninsula from * the north, departures turning out over the Pacific, a slow light aircraft @@ -296,6 +297,11 @@ export const SAMPLE_MARKERS: Marker[] = [ * these prefixes, which keeps a demo from looking like a feed of actual * traffic. Nothing here is observed, and `flights.ts` explains at length why * this project ships a simulator instead of a client for somebody's live data. + * + * The unqualified name is a leftover and is kept because the app imports it. + * Every leg in it is over San Francisco, which is only the right answer for one + * of the two cities in this build; `sampleRoutesFor` is the entry point that + * knows the difference. */ export const SAMPLE_ROUTES: SimRoute[] = [ { callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 }, @@ -306,3 +312,46 @@ export const SAMPLE_ROUTES: SimRoute[] = [ { callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 }, { callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 }, ]; + +/** + * Sample traffic over the Los Angeles basin. + * + * The same idea as `SAMPLE_ROUTES` and it exists because that one was being + * flown over both cities: the SoCal board would come up with a sky whose every + * aircraft was nearly six hundred kilometres north of it, off the edge of the world + * and therefore invisible. A city that renders an empty sky looks like a city + * whose flight layer failed. + * + * Again the geography is roughly right and the callsigns are invented. LAX runs + * west almost always, so the arrivals here come in from the east over the + * basin and the departures go out over the water before turning; Burbank sits + * up the valley behind the hills, and the light aircraft is following the coast + * because in this basin that is what they do. + */ +export const SAMPLE_SOCAL_ROUTES: SimRoute[] = [ + { callsign: "CONDOR 6", from: [34.02, -117.45], to: [33.945, -118.36], fromAlt: 3500, toAlt: 400, duration: 330 }, + { callsign: "CONDOR 21", from: [33.99, -117.29], to: [33.94, -118.33], fromAlt: 4100, toAlt: 450, duration: 360 }, + { callsign: "AVOCET 12", from: [33.945, -118.42], to: [34.15, -118.84], fromAlt: 500, toAlt: 5200, duration: 190 }, + { callsign: "AVOCET 30", from: [33.68, -117.87], to: [33.34, -118.4], fromAlt: 600, toAlt: 4800, duration: 210 }, + { callsign: "CURLEW 3", from: [33.36, -117.3], to: [33.69, -117.86], fromAlt: 4200, toAlt: 500, duration: 235 }, + { callsign: "TOWHEE 9", from: [34.33, -118.82], to: [34.2, -118.36], fromAlt: 3000, toAlt: 450, duration: 175 }, + { callsign: "CONDOR 44", from: [34.36, -118.66], to: [33.32, -117.28], fromAlt: 9200, toAlt: 9800, duration: 300 }, + { callsign: "SANDPIPER 4", from: [33.6, -118.02], to: [34.03, -118.62], fromAlt: 900, toAlt: 1100, duration: 330 }, +]; + +/** + * The right sample sky for a city, and something defensible for a city nobody + * has drawn one for. + * + * Keyed on `city.id` rather than on position because the hand-placed corridors + * are the whole value here: knowing that arrivals come down the peninsula and + * that LAX departs to the west is knowledge about two named places, and there + * is no way to derive it from a bounding box. What *can* be derived is legs + * that are at least in the right region, which is what `syntheticRoutes` does + * and what any third city gets until somebody sits down with a chart. + */ +export function sampleRoutesFor(city: Pick): SimRoute[] { + if (city.id === "sf") return SAMPLE_ROUTES; + if (city.id === "socal") return SAMPLE_SOCAL_ROUTES; + return syntheticRoutes(regionOf(city)); +} diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 7a43f9b..2f5333d 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -131,8 +131,26 @@ export function createBlocks(world: World): THREE.InstancedMesh { const [lat, lng] = world.unproject(x, z); if (!world.pointInPolygon(lat, lng, district.polygon)) continue; - if (!world.isLand(lat, lng)) continue; - if (world.pointInAny(lat, lng, world.city.parks)) continue; + /** + * Land and parks come off the lattice; the district polygon does not. + * + * The three tests used to be three exhaustive polygon walks each, and + * on the Bay Area's 186k candidate lots that was 240 ms of the boot's + * main thread — the largest single item in it, spent re-deriving what + * the heightfield Worker had already worked out for the whole board. + * `isLandSampled` and `inParkSampled` read that answer and fall through + * to the exact test only on a lattice cell that straddles the edge, so + * the coastline and the park boundaries are still decided by the + * polygons; see `World.sampled`. Same 186k lots, 19 ms. + * + * The district stays exact because there is no mask for it: districts + * are not a property of the lattice, they overlap, and San Francisco + * declares fifty-two of them. It is also the cheap one — the polygons + * are a dozen vertices and the bounding box rejects almost everything, + * which is 47 ms against the coastline's 235. + */ + if (!world.isLandSampled(lat, lng)) continue; + if (world.inParkSampled(lat, lng)) continue; if (rand() > coverage) continue; // yards, car parks, the unbuilt lots // Cubed, so tall buildings stay rare and the skyline keeps a diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 0bbd29a..5dd9bb6 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -16,7 +16,7 @@ import * as THREE from "three"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; -import type { Aircraft, FlightSource } from "./types.ts"; +import type { Aircraft, City, FlightSource } from "./types.ts"; import { seededRandom, type World } from "./world.ts"; /** A route the simulator flies: great-circle-ish, with a climb or descent. */ @@ -31,6 +31,156 @@ export interface SimRoute { duration: number; } +// ---- Where the sky is ----------------------------------------------------- + +/** A point on the ground. `City.center` is one; so is a query to a feed. */ +export interface Place { + lat: number; + lng: number; +} + +/** + * The patch of sky a source is being asked about. + * + * A circle rather than the city's rectangle, because a circle is the query + * every traffic feed actually offers: adsb.lol and airplanes.live both take a + * point and a radius, and a receiver on a roof takes nothing at all and gives + * you whatever it can hear. Turning the board into a circle here means the + * shape that crosses the wire is the shape the upstream wants, rather than a + * rectangle each adapter has to circumscribe on its own and get subtly + * different. + * + * This type exists because for a while the server was the only thing that knew + * where the traffic was — one `TERA_ORIGIN_LAT/LNG` pair, fixed at boot, for a + * map with two metros nearly six hundred kilometres apart. Every viewer of + * the SoCal board was being handed San Francisco's aircraft, which do not + * merely look wrong: they project to scene coordinates a long way off the board + * and the sky comes up empty. Where to look is a parameter now, and it comes + * from the city being rendered. + */ +export interface SkyRegion { + center: Place; + /** Nautical miles from `center`, because that is the unit ADS-B feeds take. */ + radiusNm: number; +} + +/** + * One nautical mile is one minute of latitude. That is the definition of the + * unit, not an approximation of it, which is why there is no fudge factor here. + */ +const NM_PER_DEGREE = 60; + +/** + * Distance in nautical miles, on a flat earth. + * + * Equirectangular rather than haversine, deliberately. This runs once per + * aircraft per poll — several hundred times a second in the worst case a busy + * live feed can produce — and over the hundred kilometres a city board spans + * the two answers differ by well under a tenth of a percent. Nothing + * downstream is measuring anything: the answers feed a radius query and an + * is-this-on-my-board test, and both carry slack counted in tens of kilometres. + */ +export function distanceNm(from: Place, to: Place): number { + const dLat = to.lat - from.lat; + const dLng = (to.lng - from.lng) * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLng) * NM_PER_DEGREE; +} + +/** + * The circle that covers a city's board, measured from the city's own centre. + * + * Not from the centre of `bounds`, which is a different point: San Francisco's + * `center` is the city and its board runs forty kilometres down the peninsula, + * so the two are about twenty kilometres apart. The radius is therefore taken + * to the furthest of the four corners, and a circle drawn from that far + * off-centre reaches well past the board on the near side. + * + * That is the right error to make. Aircraft on approach are outside the board + * by definition and are the ones worth watching; a query clipped to the + * rendered rectangle would drop every arrival at the moment it became + * interesting and pop it into existence over the runway. `marginNm` is more of + * the same, and is why the default is not zero. + */ +export function regionOf(city: Pick, marginNm = 15): SkyRegion { + const { minLat, maxLat, minLng, maxLng } = city.bounds; + const corners: Place[] = [ + { lat: minLat, lng: minLng }, + { lat: minLat, lng: maxLng }, + { lat: maxLat, lng: minLng }, + { lat: maxLat, lng: maxLng }, + ]; + let radiusNm = 0; + for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner)); + return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) }; +} + +/** Whether a position is in the region, with optional slack in nautical miles. */ +export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean { + return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm; +} + +/** + * Plausible traffic for a region nobody has authored routes for. + * + * `adapters/sample.ts` has hand-placed corridors for the two cities in this + * build and they are much better than this: real arrivals come down the real + * approach, and that is most of what makes a sky read as *this* city's sky + * rather than as motion. What follows is what a third city gets on the day it + * is added and before anybody has done that work — chords across the region at + * airliner altitudes, deterministic from the seed so that two viewers agree + * about where everything is. + * + * The alternative floor was an empty sky, and an empty sky over a city is not + * read as "no traffic today", it is read as a broken layer. Every leg here is + * inside the region by construction, which is the one property the previous + * arrangement could not offer: the constant it used was San Francisco. + */ +export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] { + const rand = seededRandom(seed); + const degPerNm = 1 / NM_PER_DEGREE; + // Longitude degrees are shorter than latitude degrees everywhere but the + // equator, so an east–west offset in nautical miles is more of them. + const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180); + const routes: SimRoute[] = []; + + for (let i = 0; i < count; i++) { + const bearing = rand() * Math.PI * 2; + // Push the chord off the centre so the legs are not six spokes through + // downtown. ±60% of the radius crosses the board at a spread of depths. + const offset = (rand() * 1.2 - 0.6) * region.radiusNm; + const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1)); + const alongE = Math.sin(bearing); + const alongN = Math.cos(bearing); + const from = { + lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm, + lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm, + }; + const to = { + lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm, + lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm, + }; + + // A third arriving, a third departing, a third crossing high. A board where + // everything is at cruise has no altitude ramp to read and no reason for + // the colour band in `createFlightLayer` to exist. + const kind = i % 3; + const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800; + const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400; + // Eight seconds a nautical mile is about 450 knots, which is an airliner. + const duration = Math.round(half * 2 * 8); + + routes.push({ + callsign: `SIM ${i + 1}`, + from: [from.lat, from.lng], + to: [to.lat, to.lng], + fromAlt: Math.round(fromAlt), + toAlt: Math.round(toAlt), + duration, + }); + } + return routes; +} + /** * Traffic that behaves like the real thing without being it: aircraft move * along fixed legs at fixed speeds, looping, with each one offset in phase so @@ -86,6 +236,18 @@ function nowSeconds(): number { return (typeof performance !== "undefined" ? performance.now() : 0) / 1000; } +/** + * How long a snapshot is still worth drawing after the feed stops answering. + * + * A minute, which at this source's eight-second interval is seven missed polls + * in a row — well past a dropped request and into "the feed is gone". Below + * that the last snapshot is held, because the alternative is that one timeout + * empties the sky, `createFlightLayer` drops every track it was interpolating, + * and the next good poll builds them all again from scratch: a full-screen + * flicker of every aircraft and every trail, caused by nothing. + */ +const ADSB_HOLD_SECONDS = 60; + /** * Community ADS-B, for when real traffic is wanted. * @@ -93,23 +255,36 @@ function nowSeconds(): number { * volunteer-fed ADS-B and are the sources this project can point at without a * licence problem. The best answer long-term is an RTL-SDR on a fleet box: * first-party data, nothing to comply with. + * + * The region is required and has no default. It used to default to a point in + * San Francisco, which is a fine centre for one of the two cities in this build + * and a five-hundred-kilometre error for the other — and a wrong default is + * worse than a missing one, because it produces a sky rather than a type error. */ export class AdsbFlights implements FlightSource { readonly interval = 8; + private held: Aircraft[] = []; + private heldAt = 0; + constructor( private readonly endpoint: string, - private readonly radiusNm = 25, - private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 }, + private readonly region: SkyRegion, ) {} async poll(): Promise { - const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`; + const { lat, lng } = this.region.center; + const url = `${this.endpoint}/v2/point/${lat}/${lng}/${Math.round(this.region.radiusNm)}`; try { const res = await fetch(url); - if (!res.ok) return []; + if (!res.ok) return this.hold(); const body = (await res.json()) as { ac?: RawAircraft[] }; - return (body.ac ?? []) + this.held = (body.ac ?? []) .filter((a) => typeof a.lat === "number" && typeof a.lon === "number") + // The endpoint takes a radius and is trusted to honour it, but a + // receiver feeding one of these networks hears whatever it hears and + // some deployments serve the lot. Anything outside the region projects + // to a scene coordinate off the board. + .filter((a) => inRegion(this.region, a.lat as number, a.lon as number)) .map((a) => ({ id: a.hex ?? `${a.flight ?? "?"}`, callsign: a.flight?.trim(), @@ -119,11 +294,19 @@ export class AdsbFlights implements FlightSource { altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000, heading: typeof a.track === "number" ? a.track : 0, })); + this.heldAt = nowSeconds(); + return this.held; } catch { // A dead feed must not take the render loop with it. - return []; + return this.hold(); } } + + /** The last snapshot, until it is old enough that an empty sky is the truth. */ + private hold(): Aircraft[] { + if (nowSeconds() - this.heldAt > ADSB_HOLD_SECONDS) this.held = []; + return this.held; + } } interface RawAircraft { diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 11ca34c..6e0b637 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -8,13 +8,23 @@ * one renderer serve a private map coloured by pipeline state and a public one * coloured by sector without either being a fork. * - * The renderer and the loop live in `Stage`; the camera, lights, flights and - * picking live in a `SceneKit`. What is left here — and it is the only thing - * that ought to be here — is the city itself: which layers go in the scene, - * where a chapter puts the camera, and what a pick means. An office builds the - * same two pieces with its own answers and swaps in on the same `Stage`, which - * keeps this city alive and paused rather than rebuilding its heightfield — for - * the Bay Area, about 2.3 s — on the way back. See CONTRACT.md §1. + * The renderer and the loop live in `Stage`, which is **handed in and not + * built here**: one renderer serves the canvas for the life of the page, and a + * city is a thing that is put on it and taken off again. Building a Stage per + * city is what this function used to do, and `stage.ts` records what it cost — + * every switch orphaned a 2048² shadow map on the GL context, because + * `WebGLRenderer.dispose()` frees none of a renderer's own textures. + * + * The camera, lights, flights and picking live in a `SceneKit`. What is left + * here — and it is the only thing that ought to be here — is the city itself: + * which layers go in the scene, where a chapter puts the camera, and what a + * pick means. An office builds the same two pieces with its own answers and + * swaps in on the same `Stage`, which keeps this city alive and paused rather + * than rebuilding it on the way back. + * For the Bay Area that is about 2.2 s of layer construction, of which roughly + * 730 ms is the heightfield — and the heightfield is now the only part of it + * that happens off the main thread, so a rebuild would be 2.2 s of *frozen* + * page rather than 2.2 s of busy one. See CONTRACT.md §1. */ import * as THREE from "three"; @@ -23,7 +33,7 @@ import { createNightLights, type NightLights } from "./nightlights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { createSceneKit, type Pose } from "./scenekit.ts"; -import { createStage, type Stage, type StageScene } from "./stage.ts"; +import type { Stage, StageScene } from "./stage.ts"; import { createBridges, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import type { @@ -35,7 +45,7 @@ import type { MarkerPalette, ScenePalette, } from "./types.ts"; -import { World } from "./world.ts"; +import { World, type FieldProgress } from "./world.ts"; export interface SceneOptions { city: City; @@ -49,15 +59,32 @@ export interface SceneOptions { * until somebody wires up the sun is not a scene that boots with no config. */ lighting?: LightingState; + /** + * Fires while the heightfield builds, several times a second. The caller + * decides what to say about it; the engine only reports a phase and a + * fraction. See `FieldProgress`. + */ + onProgress?: (progress: FieldProgress) => void; + /** + * Abandons the build. `createScene` then resolves to `null` having allocated + * no geometry and having touched the stage not at all — the point of aborting + * is that the next city gets the machine to itself, and a half-built scene + * parked on the stage defeats that. + */ + signal?: AbortSignal; } export interface SceneHandle { world: World; chapters: Chapter[]; /** - * The renderer and the loop. An office is swapped in with - * `stage.setScene(officeScene)` and this city back in the same way; the one - * that steps out is paused, not thrown away. + * The stage this city was built on, which it uses and does not own. An office + * is swapped in with `stage.setScene(officeScene)` and this city back in the + * same way; the one that steps out is paused, not thrown away. + * + * `dispose()` below takes the city off it and leaves it running. Whoever + * built the stage disposes it, and on this page nobody does — it lives as + * long as the canvas does. */ stage: Stage; /** This city, as the thing `stage.setScene` takes. */ @@ -74,15 +101,45 @@ export interface SceneHandle { current(): string; onChapterChange(fn: (id: string) => void): void; setMarkers(markers: Marker[]): void; + /** Take this city off the stage and release everything it built. */ dispose(): void; } -export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle { +/** + * Build a city on a stage. Resolves to `null` if the build was abandoned. + * + * ## Why this is async, and why the handle is not + * + * `SceneHandle` is unchanged: every method on it is synchronous and every field + * on it is real by the time you hold one. Only getting one takes a moment. + * + * The alternative was tried on paper and is worse. Handing back a handle + * immediately means handing back a handle whose `stageScene` holds an empty + * scene, whose `flyTo` cannot know where the ground is, and whose `world` + * answers `groundAt` by building the heightfield on the main thread — the exact + * block this change exists to remove, reintroduced by the first caller who + * forgets to wait. Every one of those methods would need a "not yet" branch and + * a queue, and the queue would be the real API. + * + * So the wait is where the wait actually is. The cost is that it ripples: the + * app has to `await mountCity`, and `createMinimap` has to be constructed after + * this resolves rather than alongside it. That is a handful of `await`s in + * `main.ts` against an engine that cannot lie about whether its ground exists. + */ +export async function createScene( + stage: Stage, + options: SceneOptions, +): Promise { const { city } = options; const world = new World(city); + // First, so an abandoned build has nothing to tear down: everything below + // this line allocates, and a city that is no longer wanted should not have + // built a single buffer. + const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress }); + if (!ready) return null; + const pal = paletteFor(world); - const stage = createStage(canvas); const scene = new THREE.Scene(); /** @@ -238,8 +295,24 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S markerLayer.setMarkers(markers); }, dispose() { - // Stage first, so nothing ticks a half-disposed scene. - stage.dispose(); + /** + * Off the stage, then released — and the stage itself is left running. + * + * The order is load-bearing and it used to be the other way round, with + * a comment saying "Stage first, so nothing ticks a half-disposed scene". + * The concern was real and the remedy was the bug: `stage.dispose()` + * calls `renderer.dispose()`, which replaces the `properties` WeakMap, + * and `stageScene.dispose()` then walks the scene disposing materials + * that the renderer no longer has an entry for. `three` reads + * `properties.get(material).programs`, finds `undefined`, and quietly + * skips `gl.deleteProgram` for every one of them — so disposing in that + * order freed nothing it was written to free. + * + * `setScene(null)` answers the ticking concern on its own and answers it + * better: the loop drops this scene on the very next frame, and the + * renderer keeps its bookkeeping so the disposals below actually land. + */ + if (stage.current() === stageScene) stage.setScene(null); stageScene.dispose(); }, }; diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts index 884e1bb..892c5fe 100644 --- a/src/engine/scenekit.ts +++ b/src/engine/scenekit.ts @@ -12,12 +12,46 @@ * the sun — `Atmosphere` for a city, a fixed constant for an office — computes * the state and hands it over, and nothing writes back. That is the one * direction CONTRACT.md §4 asks for. + * + * It is also where a finger meets the map. `OrbitControls` gives one gesture + * vocabulary to both a mouse and a thumb, and the two want different answers — + * so the kit swaps a small input profile on every `pointerdown` according to + * `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop + * changes; the touch values are only ever installed by a touch. + * + * The one thing that is *not* here is `touch-action`. `OrbitControls.connect()` + * sets `touchAction = "none"` on the element it is handed, and `index.html` + * also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule + * is what covers the second or two between first paint and this module + * existing, and a drag on the canvas in that window would otherwise scroll and + * rubber-band the page instead. */ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { deviceProfile } from "./stage.ts"; import type { LightingState } from "./types.ts"; +/** + * How much slower one finger turns the camera than one mouse. + * + * `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes, + * and it has one `rotateSpeed` for both, so this is a compromise between them. + * Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall + * phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle` + * leaves about 85° of usable travel against a mapping that spends 360° over a + * screen height, so a tilt hits its clamp in the first 200 px and the camera + * feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px + * and costs the azimuth a swing it can afford — the vertical axis is the + * binding constraint, and there is only one dial. + */ +const TOUCH_ROTATE_SCALE = 0.7; + +/** How far a finger may wander and still be a tap, in CSS px. */ +const TAP_SLOP = 12; +/** How long a finger may rest and still be a tap, in ms. */ +const TAP_MS = 400; + /** Where the camera sits and what it looks at. Scene units, whatever they mean. */ export interface Pose { position: THREE.Vector3; @@ -101,16 +135,108 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { const controls = new OrbitControls(camera, dom); controls.enableDamping = true; - controls.dampingFactor = options.dampingFactor ?? 0.07; + const baseDamping = options.dampingFactor ?? 0.07; + controls.dampingFactor = baseDamping; controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane controls.minDistance = options.minDistance ?? 12; controls.maxDistance = options.maxDistance ?? 340; + // ---- Input -------------------------------------------------------------- + + /* + * The gesture map is three.js's default and it is already the right one: + * `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers + * pinch and drag *at the same time*, which is how every map on a phone + * behaves and is why it is not split into separate two-finger modes here. + * + * Zoom needs nothing scaled to the board, and it is worth saying why, because + * `scene.ts` records what happened the last time a distance was treated as a + * constant. A pinch dollies by `(endSeparation / startSeparation) ^ + * zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both + * multiply the camera's current distance, so SoCal's 393-unit board and the + * Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with + * no knowledge of either number. The only board-sized values in the gesture + * path are `minDistance` and `maxDistance`, which the caller already derives. + */ + + /** + * A mouse and a thumb are given different values for the three settings where + * one answer cannot serve both, swapped in on `pointerdown` by `pointerType`. + * + * The alternative — pick the values once from a device probe — is wrong on + * every laptop with a touchscreen, where both inputs are live at once and the + * user switches between them mid-session. Keying off the event that is + * actually happening is both simpler and correct, and it means the desktop + * path is bit-for-bit what it was: the touch values do not exist until a + * touch installs them. + * + * - **`screenSpacePanning`** is three's default `true`, which pans along the + * camera's own up vector. On a map seen from above that lifts the target + * off the ground as you drag, and the board slides away underneath. For two + * fingers it goes to `false`: pan in the ground plane, so the board tracks + * the fingers. Left alone for the mouse, where right-drag pan is + * long-standing behaviour and someone would notice it change. + * - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point + * between the fingers, which is the whole reason people pinch a particular + * neighbourhood. It moves `controls.target` as well as the camera, so the + * orbit centre drifts toward whatever was pinched — accepted deliberately, + * because on a map that drift *is* the interaction. The wheel keeps zooming + * to the centre of the view. + * - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`. + */ + const mouseInput = { + rotateSpeed: controls.rotateSpeed, + screenSpacePanning: controls.screenSpacePanning, + zoomToCursor: controls.zoomToCursor, + }; + + function applyPointerProfile(pointerType: string) { + const touch = pointerType === "touch"; + controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1); + controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning; + controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor; + } + + /** + * A wheel arrives with no pointer, so it cannot announce its own type. Any + * wheel at all means a mouse or a trackpad is in the room, and without this a + * hybrid laptop that was last touched keeps the touch profile — and scrolls + * toward wherever the finger happened to be, once, for no visible reason. + * + * `OrbitControls` registered its own wheel handler first, so the notch that + * performs the reset is itself still anchored to the old point and only the + * next one is centred. One notch, on a machine that has both inputs and used + * both in the same breath; the fix for that costs finger-counting state and + * buys a frame. + */ + function onWheel() { + applyPointerProfile("mouse"); + } + dom.addEventListener("wheel", onWheel, { passive: true }); + + /** + * iOS pinches the *page* as well as the map. + * + * `touch-action: none` stops Safari's double-tap zoom and its scroll, but + * WebKit's own `gesture*` events are not covered by it, and a two-finger + * pinch that begins on the canvas can still scale the whole document — + * leaving the UI enormous, half off-screen, and with no gesture left that + * undoes it. Refusing the three of them costs nothing anywhere else: no other + * engine implements the events at all. + */ + const preventGesture = (event: Event) => event.preventDefault(); + dom.addEventListener("gesturestart", preventGesture); + dom.addEventListener("gesturechange", preventGesture); + dom.addEventListener("gestureend", preventGesture); + // ---- Light rig ---------------------------------------------------------- const sun = new THREE.DirectionalLight(0xffffff, 1); sun.castShadow = true; - const mapSize = options.shadowMapSize ?? 2048; + // The default is the device's, not a constant: a phone gets a smaller map for + // the reasons written out in `stage.ts`. A caller that knows better — an + // office, at a hundredth of the city's scale — passes its own. + const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize; sun.shadow.mapSize.set(mapSize, mapSize); sun.shadow.camera.near = options.shadowNear ?? 10; sun.shadow.camera.far = options.shadowFar ?? 520; @@ -175,6 +301,19 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { let flying = false; let flightT = 0; + const motionQuery = + typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : null; + let reducedMotion = motionQuery?.matches ?? false; + + function onMotionChange(event: MediaQueryListEvent) { + reducedMotion = event.matches; + // Mid-flight when the preference flips: land now rather than finish the arc. + if (reducedMotion && flying) setPose(to); + } + motionQuery?.addEventListener("change", onMotionChange); + function setPose(pose: Pose) { flying = false; camera.position.copy(pose.position); @@ -182,7 +321,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { controls.update(); } + /** + * A chapter flight is the largest motion this app makes: the whole field of + * view sweeps and rotates for a second and a half, unrequested by anyone who + * only clicked a name in a list. That is the case `prefers-reduced-motion` + * exists for, so under it the flight becomes a cut. `main.ts` already reached + * the same conclusion for a minimap seek and says so there. + * + * Damping is left alone, and the distinction is worth stating: damping only + * ever follows a finger or a mouse that is currently moving, and it settles + * in a few frames after it stops. It is the response to a gesture, not motion + * the interface started on its own. + */ function flyTo(pose: Pose) { + if (reducedMotion) { + setPose(pose); + return; + } from.position.copy(camera.position); from.target.copy(controls.target); to.position.copy(pose.position); @@ -203,14 +358,68 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { // thrown away. let pointerDirty = false; - function onPointerMove(event: PointerEvent) { + function aimAt(clientX: number, clientY: number) { const rect = dom.getBoundingClientRect(); - pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1; pointerDirty = true; } + + // A moving finger is not hovering; see the tap block below. + function onPointerMove(event: PointerEvent) { + if (event.pointerType === "touch") return; + aimAt(event.clientX, event.clientY); + } dom.addEventListener("pointermove", onPointerMove); + /** + * There is no hover on a touch screen, and pretending otherwise is how a map + * ends up flashing a detail card for every marker a thumb happens to sweep + * across on its way to turning the board. A finger only reports where it is + * *while it is pressed*, which is exactly when it is doing something else. + * + * So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and + * `TAP_MS`, and that point is picked. Anything longer or further is a gesture + * and picks nothing. The pick then survives the finger leaving the glass — a + * card raised by a tap has to stay up to be read — and is cleared by the next + * touch anywhere, which is what makes tapping empty water the way to dismiss + * it. + * + * 12 px of slop, not zero: a thumb pivots while it presses, and a tap that + * wandered a millimetre is still a tap. Past that the camera has visibly + * moved, and something that moved the map should not also have selected + * something on it. + */ + + /** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */ + let tapPointer = -1; + let tapX = 0; + let tapY = 0; + let tapAt = 0; + + function onPointerDown(event: PointerEvent) { + applyPointerProfile(event.pointerType); + if (event.pointerType !== "touch") return; + resetPick(); + tapPointer = tapPointer === -1 ? event.pointerId : -2; + tapX = event.clientX; + tapY = event.clientY; + tapAt = event.timeStamp; + } + dom.addEventListener("pointerdown", onPointerDown); + + function onPointerUp(event: PointerEvent) { + if (event.pointerType !== "touch") return; + const wasTap = + tapPointer === event.pointerId && + event.timeStamp - tapAt <= TAP_MS && + Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP; + tapPointer = -1; + if (wasTap) aimAt(event.clientX, event.clientY); + } + dom.addEventListener("pointerup", onPointerUp); + dom.addEventListener("pointercancel", onPointerUp); + function resetPick() { pointerDirty = false; if (picked === null) return; @@ -219,7 +428,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { dom.style.cursor = ""; wasPicking?.onChange(null); } - dom.addEventListener("pointerleave", resetPick); + + // Not for touch. A finger lifting fires `pointerleave` immediately after + // `pointerup`, so honouring it here would wipe the pick a tap had just made, + // in the same frame, every time. + function onPointerLeave(event: PointerEvent) { + if (event.pointerType === "touch") return; + resetPick(); + } + dom.addEventListener("pointerleave", onPointerLeave); function repick() { if (!picking || !pointerDirty) return; @@ -253,6 +470,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { }, resetPick, tick(dt) { + /** + * `OrbitControls` damps per *frame*, not per second: every `update()` + * moves the camera `dampingFactor` of the way to where the input asked + * for. So the same 0.07 is a different feel on every refresh rate — twice + * as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a + * 144 Hz monitor, which is why the settle on a laptop and the settle on a + * handset never matched. + * + * Re-deriving it from the frame time fixes both ends with the same line. + * At exactly 60 fps this returns `baseDamping` unchanged, so the desktop + * default it was tuned at is preserved to the digit; away from 60 it + * holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to + * 50 ms, so the exponent cannot run away after a stall and snap the + * camera. + */ + controls.dampingFactor = + dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping; if (flying) { flightT = Math.min(1, flightT + dt * flightSpeed); // easeInOutCubic — a flight that starts and lands gently @@ -268,7 +502,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { }, dispose() { dom.removeEventListener("pointermove", onPointerMove); - dom.removeEventListener("pointerleave", resetPick); + dom.removeEventListener("pointerdown", onPointerDown); + dom.removeEventListener("pointerup", onPointerUp); + dom.removeEventListener("pointercancel", onPointerUp); + dom.removeEventListener("pointerleave", onPointerLeave); + dom.removeEventListener("wheel", onWheel); + dom.removeEventListener("gesturestart", preventGesture); + dom.removeEventListener("gesturechange", preventGesture); + dom.removeEventListener("gestureend", preventGesture); + motionQuery?.removeEventListener("change", onMotionChange); dom.style.cursor = ""; picking = null; controls.dispose(); diff --git a/src/engine/stage.ts b/src/engine/stage.ts index e6b85ed..c2d809e 100644 --- a/src/engine/stage.ts +++ b/src/engine/stage.ts @@ -14,6 +14,32 @@ * rebuild on the way back out. Stage therefore disposes nothing it did not * create — whoever built a `StageScene` disposes it, when they actually mean * to be rid of it. See CONTRACT.md §1. + * + * ## One Stage per canvas, for the life of the page + * + * The corollary, and it is not optional. `createScene` used to build a Stage of + * its own per city, so every switch between the Bay Area and SoCal constructed + * another `WebGLRenderer` on the same GL context and abandoned the last one. + * `WebGLRenderer.dispose()` frees **no textures at all** — read it in + * `three.module.js`: `background`, `renderLists`, `renderStates`, `properties`, + * `objects`, `programCache` and the rest, and not `textures` — so each switch + * orphaned the renderer's seven 1x1 defaults and its 2048² shadow map. That is + * 16.8 MB of GPU memory per switch, invisible to a JS heap snapshot, plus about + * ten shader programs, growing monotonically and never plateauing: ten switches + * measured 88 live textures and 117 live programs against 0 calls to + * `gl.deleteTexture`. + * + * There is no version of this that `dispose()` fixes, because the leaked + * textures are the renderer's own and it does not free them. The only fix is + * not to build a second renderer, so the app constructs one Stage next to the + * canvas and hands it to every scene it builds. `createScene` takes a `Stage` + * rather than a canvas for that reason, and the office already worked this way. + * + * It also decides how much machine there is to spend, because the renderer is + * what spends it: `deviceProfile()` below is the single place that answers + * "is this a phone", and `scenekit.ts` imports it rather than asking again, so + * the two halves of the engine cannot end up with different opinions about the + * same handset. */ import * as THREE from "three"; @@ -31,25 +57,155 @@ export interface StageScene { export interface Stage { renderer: THREE.WebGLRenderer; - setScene(s: StageScene): void; + /** + * Show a scene, or `null` for none at all. + * + * `null` is what a caller about to dispose a scene passes first: the loop + * stops touching it that instant, which is the whole of the "nothing ticks a + * half-disposed scene" rule, and it costs no renderer state. The stage keeps + * running with nothing to draw, which is exactly what it does between the + * first frame and the first city. + */ + setScene(s: StageScene | null): void; current(): StageScene | null; + /** + * Retire the renderer and the loop. + * + * Called once, at the end of the page's life, by whoever built it — which is + * **not** a scene. See the note at the top of this file about what + * `WebGLRenderer.dispose()` does and does not free. + */ dispose(): void; } export interface StageOptions { antialias?: boolean; - /** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */ + /** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */ maxPixelRatio?: number; shadows?: boolean; } +/** + * What kind of machine this is, to the extent a browser will say. + * + * There is no honest way to ask a page how fast its GPU is. The two things + * usually reached for are both worse than useless here: + * `navigator.hardwareConcurrency` counts CPU threads, and a phone with eight + * of them and a phone with four tell you nothing about their fill rate — + * Safari also rounds it and Chrome caps it, so the same handset answers + * differently in two browsers. `navigator.deviceMemory` is Chromium-only and + * bucketed to powers of two. Neither is a proxy for the thing being decided. + * + * So this does not pretend to measure performance. It asks the one question it + * can answer correctly — *is this a phone* — out of a coarse primary pointer + * and a short viewport edge, and applies a fixed, documented budget to that + * answer. A tablet is not a phone: `(pointer: coarse)` is true on an iPad and + * its short edge is 820, so it lands on the desktop budget, which is right + * because it has the screen and usually the silicon for it. + * + * The 600px edge is deliberately `index.html`'s own small breakpoint, so the + * renderer's idea of "phone" and the stylesheet's cannot drift apart. + * + * Sampled once, by whoever constructs. Re-deriving it on resize would let a + * rotation or a desktop window drag change the pixel ratio mid-session, which + * costs a full reallocation of every render target to buy nothing. + */ +export interface DeviceProfile { + /** Coarse pointer and a short viewport edge. A phone, as far as anyone can tell. */ + handheld: boolean; + /** Device pixel ratio ceiling. */ + maxPixelRatio: number; + /** Default shadow map edge, in texels. Read by `scenekit.ts`. */ + shadowMapSize: number; +} + +export function deviceProfile(): DeviceProfile { + const coarse = + typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches; + const shortEdge = Math.min(window.innerWidth, window.innerHeight); + const handheld = coarse && shortEdge <= 600; + + /** + * 1.5, not 2, on a phone — and not 1 either. + * + * A 390 x 844 iPhone reports a device pixel ratio of 3. Capped at 2 that is + * 780 x 1688, 1.3 megapixels of fragments, every one of them shaded against + * a sun, a hemisphere, an ambient and a shadow lookup, for a scene carrying + * about 140k building instances. At 1.5 it is 585 x 1266, 0.74 Mpx: 56% of + * the fragments for a frame that is still supersampled relative to CSS + * pixels. Dropping to 1 would halve it again, but then a 3x panel is + * upscaling by three and the whole map goes soft — which reads as a cheap + * page rather than a fast one. + * + * The antialias flag stays on there. MSAA on the tile-based GPUs in phones + * resolves inside tile memory and is close to the cheapest edge quality + * available; raising the pixel ratio to buy the same smoothing costs + * quadratically. Spend it on MSAA, not on pixels. + */ + return { + handheld, + maxPixelRatio: handheld ? 1.5 : 2, + /* + * Halved on a phone, and the city barely knows. + * + * Check what is actually in that map before defending its size. On the city + * board the only casters are the buildings, the landmarks and the bridges — + * `terrain.ts` sets `receiveShadow` and never `castShadow`, so the hills' + * relief is the Lambert term and not a shadow at all. And `scene.ts` hands + * the kit a shadow extent of 0.75 board spans, which for the Bay Area's + * 1003 units is a 1504-unit box: at 2048 texels that is 0.73 units, about + * 69 m at this city's scale, and a building footprint is one texel or less. + * The map is already quantising past the things in it. + * + * So 1024 on a phone costs the map a resolution it was not using. An office + * passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is + * four centimetres a texel and a desk very much does cast. + */ + shadowMapSize: handheld ? 1024 : 2048, + }; +} + export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage { + const profile = deviceProfile(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2)); + renderer.setPixelRatio( + Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio), + ); renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); if (options.shadows ?? true) { renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; + /** + * `PCFShadowMap`, and phones get it too. + * + * This line said `PCFSoftShadowMap` and had done since the first commit, + * which in three r182 is not soft and is not PCF. Two things happened + * upstream. `WebGLProgram`'s define table now maps only `PCFShadowMap` and + * `VSMShadowMap`, and everything else falls through to + * `SHADOWMAP_TYPE_BASIC` — one unfiltered tap, hard stair-stepped edges. + * The runtime downgrade that is supposed to catch this reads `lights.type` + * off the light *array* rather than off the shadow map, so it is always + * `undefined` and the deprecation warning never prints. The result was the + * cheapest and ugliest shadows in the library, chosen by nobody, announced + * to no one. + * + * `PCFShadowMap` costs five Vogel-disk samples through a hardware + * comparison sampler, with the pattern rotated per pixel by interleaved + * gradient noise. That is more than one tap, and it is the reason a phone + * can be given a 1024 map (see `deviceProfile`) and still look better than + * it did on an unfiltered 2048: filtering buys more here than resolution + * does, because at this board's shadow extent the map quantises to a city + * block either way. + * + * Switching shadows off on a phone was the other option and it cannot be + * taken at this line. This flag is the *renderer's*, and the renderer is + * shared: the office swaps onto the same `Stage` (CONTRACT.md §1) at a + * hundredth of the city's scale, where the shadows under the desks are the + * whole read of depth in the room. Killing them here to speed up a map that + * is quantising them away anyway would gut Spaces on the one class of + * device that most needs Spaces to be worth the download. The saving lives + * in the map size instead, which each scene chooses for itself. + */ + renderer.shadowMap.type = THREE.PCFShadowMap; } let currentScene: StageScene | null = null; @@ -62,6 +218,11 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = { // Compared against CSS pixels, because `canvas.width` is in device pixels and // differs from `clientWidth` on every retina display — checking it would call // `setSize` on every single frame. + // + // This is also what keeps mobile Safari honest. The canvas is `100dvh`, and + // the viewport grows and shrinks continuously as the URL bar collapses under + // a scroll-like gesture; that emits no `resize` event worth relying on. The + // per-frame comparison catches it as a size change like any other. let lastWidth = 0; let lastHeight = 0; @@ -111,6 +272,7 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = { if (s === currentScene) return; currentScene?.onExit?.(); currentScene = s; + if (!s) return; // The incoming camera may never have seen this canvas, and the canvas may // have been resized while the scene was paused. applyViewport(s); diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 5d63e14..35eecc8 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -46,16 +46,21 @@ export function paletteFor(world: World): ScenePalette { * and McLaren, all of which are parks and get their green from being in * `city.parks`. Everywhere else stays city-coloured however high it goes, and * the buildings do the rest of the talking. + * + * `inPark` arrives as an argument rather than being worked out here. This used + * to call `world.pointInAny(lat, lng, world.city.parks)` itself, once per + * emitted vertex, which on the Bay Area is 294k walks of twenty-four park + * polygons — 72 ms of main thread, on a desktop, recomputing a fact the Worker + * had already established at exactly these points on its way past. The lattice + * now carries it (`Field.park`), and the caller has the index in hand. */ function groundColor( - world: World, pal: ScenePalette, scratch: THREE.Color, - lat: number, - lng: number, + inPark: boolean, elevation: number, ): THREE.Color { - if (world.pointInAny(lat, lng, world.city.parks)) { + if (inPark) { return scratch .setHex(pal.park) .lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180)); @@ -110,7 +115,7 @@ export function createShorePlates(world: World): THREE.Mesh { */ export function createTerrain(world: World): THREE.Mesh { const pal = paletteFor(world); - const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); + const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice(); const positions: number[] = []; const colors: number[] = []; @@ -130,7 +135,7 @@ export function createTerrain(world: World): THREE.Mesh { const e = height[k] ?? 0; const [x, z] = world.project(lat, lng); positions.push(x, world.metres(e) + 0.012, z); - const c = groundColor(world, pal, scratch, lat, lng, e); + const c = groundColor(pal, scratch, park[k] === 1, e); colors.push(c.r, c.g, c.b); const id = positions.length / 3 - 1; vertexAt[k] = id; diff --git a/src/engine/terrain.worker.ts b/src/engine/terrain.worker.ts new file mode 100644 index 0000000..686df22 --- /dev/null +++ b/src/engine/terrain.worker.ts @@ -0,0 +1,87 @@ +/** + * The heightfield, built off the main thread. + * + * This worker exists for one number: on the Bay Area, producing the lattice is + * about 730 ms of unbroken synchronous work, and SoCal's is around 390 ms on + * top of a switch that already blocks for four seconds. Nothing paints and + * nothing responds while it runs — not the boot card, not the progress line + * explaining why the boot card is still up. + * + * It is deliberately thin. All the geography lives in `world.ts` and this file + * calls `computeField` exactly as the main thread would; the only thing here + * that is not in `world.ts` is the message plumbing. The alternative — a second + * copy of the sampling loop, tuned separately — produces two maps of the same + * city that differ in the fourth decimal place and agree on nothing that would + * make the difference visible. + * + * ## Why the `City` is sent whole + * + * A `City` is pure data by contract (`types.ts`), so structured clone carries it + * across as-is. That costs something — the Bay Area pack's polygons are a few + * hundred kilobytes — but it is paid once per city, against a field that comes + * back as several megabytes of transferred buffer. Sending a city *id* and + * importing the pack in here instead would drag both city modules into the + * worker chunk and break the rule that a pack is data the engine is handed, + * not data the engine knows about. + */ + +import { World, computeField, type FieldMessage, type FieldRequest } from "./world.ts"; + +/** + * `DedicatedWorkerGlobalScope` is not in `lib.dom.d.ts` and this project's + * `tsconfig.json` belongs to another session, so the two members this file + * actually touches are declared here rather than by adding `WebWorker` to + * `lib`. Narrow on purpose: if this grows a third member, that is the moment to + * ask for the lib entry instead. + */ +declare const self: { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: FieldMessage, transfer?: Transferable[]): void; +}; + +/** + * How often progress goes back over the wire. + * + * Per-row would be 656 messages for San Francisco, and every one of them is a + * task queued on the main thread — the thread this whole file exists to leave + * alone. Eight a second is enough for a bar that moves and cheap enough to be + * invisible. + */ +const PROGRESS_INTERVAL_MS = 125; + +self.onmessage = (event: MessageEvent) => { + const { city } = event.data; + try { + const world = new World(city); + let last = 0; + const field = computeField(world, (done, rows) => { + const now = performance.now(); + if (done < rows && now - last < PROGRESS_INTERVAL_MS) return; + last = now; + self.postMessage({ type: "progress", done, rows }); + }); + + // Transfer, do not copy. The Bay Area's field is a 2.1 MB `Float32Array` + // and two 533 kB `Uint8Array`s, plus the two axes; structured-cloning that + // back hands the main thread a memcpy and an allocation of everything the + // worker just saved it. After this the worker's own views are detached, + // which is fine because it is about to be terminated. + // + // Every buffer in the field is listed. A buffer left off this list is + // silently *copied* instead of moved, which is invisible in behaviour and + // is exactly the cost this postMessage exists to avoid. + self.postMessage({ type: "field", ...field }, [ + field.lats.buffer, + field.lngs.buffer, + field.height.buffer, + field.land.buffer, + field.park.buffer, + ]); + } catch (err) { + // Report rather than throw. An uncaught error in here reaches the main + // thread as an `ErrorEvent` with no message under most cross-origin rules, + // and "something went wrong somewhere" is not worth the fallback path being + // silent about. + self.postMessage({ type: "failed", message: err instanceof Error ? err.message : String(err) }); + } +}; diff --git a/src/engine/world.ts b/src/engine/world.ts index 53b3c1f..2ec3d1a 100644 --- a/src/engine/world.ts +++ b/src/engine/world.ts @@ -5,10 +5,89 @@ * One `World` per city, built once. The engine's other modules take a `World` * rather than importing constants, which is the whole reason a second city is * a data file and not a fork. + * + * ## Constructed immediately, ready later + * + * Everything here that does not touch the heightfield — `project`, `metres`, + * `pointInPolygon`, `elevationAt` — works the instant the constructor returns. + * The heightfield does not: it is half a million samples of four-octave noise + * and a distance-to-coastline, and on the Bay Area that is about 730 ms of + * unbroken main thread. It is built in a Worker, and `await world.ready()` is + * how a caller waits for it. + * + * The synchronous samplers stay synchronous, because `terrain.ts`, `blocks.ts`, + * `structures.ts`, `nightlights.ts` and `minimap.ts` call `groundAt` in tight + * loops and an `await` inside those loops would cost far more than the block it + * saved. So the split is: *becoming* ready is asynchronous, *being* ready is + * not. + * + * ## What the field carries, and why it grew + * + * Height was the first thing worth computing once and reading back, and for a + * while it was the only one. It was not the only one that was being recomputed: + * `isLand` and "is this in a park" are polygon walks over coastlines with + * hundreds of vertices, and the layer builders were asking them hundreds of + * thousands of times *on the main thread*, for points the Worker had already + * classified on its way past. So the field carries `land` and `park` too, and + * `isLandSampled`/`inParkSampled` read them. The exact predicates are still + * here, still exact, and are what the Worker itself uses. */ import type { City, LatLng } from "./types.ts"; +/** The lattice, and the three arrays sampled off it. */ +export interface Field { + latSteps: number; + lngSteps: number; + /** Latitude of every row; spacing is not uniform. See `buildAxis`. */ + lats: Float64Array; + /** Longitude of every column. */ + lngs: Float64Array; + /** Metres above sea level, row-major, `(lngSteps + 1)` wide. */ + height: Float32Array; + /** 1 where the point is on land, 0 in water. Same layout as `height`. */ + land: Uint8Array; + /** + * 1 where the point is inside `city.parks`, 0 elsewhere and everywhere wet. + * Same layout as `height`. + * + * Here rather than left to the consumers because the loop that fills it is + * already standing on the point with the coordinate in hand, and because the + * consumers are on the main thread while this is not. See `computeField`. + */ + park: Uint8Array; +} + +/** + * How the heightfield is getting on, for whoever is showing a boot card. + * + * `phase` is a stable key and not a sentence: the engine has no opinion about + * what language the page is in, and the one place that already writes this copy + * — `#boot-step` — is the app's, not the engine's. + */ +export interface FieldProgress { + phase: "heightfield"; + /** 0..1. Rows completed, which is honest: every row costs about the same. */ + fraction: number; + /** + * True when the build fell back to the main thread, so a caller can tell the + * difference between "this is slow" and "this is slow *and* the page is + * frozen, do not bother animating anything". + */ + onMainThread: boolean; +} + +export interface ReadyOptions { + /** + * Abandons the build. The promise then resolves `false` rather than + * rejecting: switching city mid-build is a normal thing for a person to do, + * not an error, and a rejection would have to be caught at every call site + * or become an unhandled rejection in the console. + */ + signal?: AbortSignal; + onProgress?: (progress: FieldProgress) => void; +} + export class World { readonly city: City; readonly lngScale: number; @@ -18,12 +97,8 @@ export class World { readonly lngSquash: number; private readonly bboxes = new WeakMap(); - private field: Float32Array | null = null; - private fieldLand: Uint8Array | null = null; - private lats: Float64Array | null = null; - private lngs: Float64Array | null = null; - private latSteps = 0; - private lngSteps = 0; + private state: Field | null = null; + private pending: Promise | null = null; constructor(city: City) { this.city = city; @@ -152,6 +227,11 @@ export class World { return this.pointInAny(lat, lng, this.city.landmasses); } + /** Inside one of the city's parks. The exact test; see `inParkSampled`. */ + inPark(lat: number, lng: number): boolean { + return this.pointInAny(lat, lng, this.city.parks); + } + // ---- Relief ------------------------------------------------------------- /** @@ -205,55 +285,102 @@ export class World { // ---- Cached heightfield ------------------------------------------------- + /** True once the heightfield exists and the samplers are cheap. */ + get built(): boolean { + return this.state !== null; + } + /** - * `elevationAt` is not cheap — every hill, four octaves of noise, and a - * distance-to-polygon per landmass. The terrain mesh wants it at hundreds of - * thousands of lattice points, and then every building, road sample and - * camera target wants it again. Computed once, read back bilinearly. + * Build the heightfield, off the main thread if the browser will let us. + * + * Resolves `true` when the field is up and the synchronous samplers are safe, + * `false` when the build was abandoned through `options.signal`. It never + * rejects and it never leaves a half-built field behind. + * + * Idempotent and single-flight: the second caller gets the first caller's + * promise, and the first caller's `signal` and `onProgress` are the ones that + * count. One `World` builds one field, once. + * + * The fallback to building here on the main thread is not a stub and is not + * optional. Workers are unavailable under `file://`, under a strict enough + * `Content-Security-Policy`, and in a handful of embedded webviews, and this + * repo's one enforced promise is that it boots with no server, no key and no + * account. A map that renders in two seconds is a slow map; a map that throws + * because `new Worker` was blocked is a broken one. */ - private buildField(): { height: Float32Array; land: Uint8Array } { - if (this.field && this.fieldLand && this.lats && this.lngs) { - return { height: this.field, land: this.fieldLand }; - } - const { bounds, cellLat, cellLng } = this.city; - const coarse = Math.max(1, this.city.coarseFactor ?? 1); - const regions = this.city.focusRegions ?? []; + ready(options: ReadyOptions = {}): Promise { + if (this.state) return Promise.resolve(true); + if (this.pending) return this.pending; + const run = this.build(options).then((ok) => { + // Cleared when the build was abandoned, so a caller that still wants this + // `World` can start another; on success it stays set and never matters, + // because `this.state` short-circuits above. + if (!ok) this.pending = null; + return ok; + }); + this.pending = run; + return run; + } - // Rectilinear but NOT uniform: fine spacing across any band that a focus - // region occupies, coarse everywhere else. See `buildAxis`. - this.lats = buildAxis( - bounds.minLat, - bounds.maxLat, - cellLat, - cellLat * coarse, - regions.map((r) => [r.minLat, r.maxLat] as [number, number]), - ); - this.lngs = buildAxis( - bounds.minLng, - bounds.maxLng, - cellLng, - cellLng * coarse, - regions.map((r) => [r.minLng, r.maxLng] as [number, number]), - ); + private async build(options: ReadyOptions): Promise { + const { signal, onProgress } = options; + if (signal?.aborted) return false; - this.latSteps = this.lats.length - 1; - this.lngSteps = this.lngs.length - 1; - const w = this.lngSteps + 1; - const height = new Float32Array((this.latSteps + 1) * w); - const land = new Uint8Array((this.latSteps + 1) * w); - for (let i = 0; i <= this.latSteps; i++) { - const lat = this.lats[i] as number; - for (let j = 0; j <= this.lngSteps; j++) { - const lng = this.lngs[j] as number; - const k = i * w + j; - const onLand = this.isLand(lat, lng); - land[k] = onLand ? 1 : 0; - height[k] = onLand ? this.elevationAt(lat, lng) : 0; + const worker = spawnFieldWorker(); + if (worker) { + const result = await runInWorker(worker, this.city, signal, onProgress); + if (result === "abandoned") return false; + if (result !== "failed") { + this.adopt(result); + return true; } } - this.field = height; - this.fieldLand = land; - return { height, land }; + + if (signal?.aborted) return false; + // Let the caller's progress line reach the glass before we take the thread + // away for the better part of a second. This is the same double-rAF trick + // `main.ts` uses around its boot card, and for the same reason: a style + // change and the work that follows it in the same task paint together, so + // the label the user was supposed to read arrives after the freeze it was + // meant to explain. + onProgress?.({ phase: "heightfield", fraction: 0, onMainThread: true }); + await nextPaint(); + if (signal?.aborted) return false; + + this.adopt(computeField(this)); + onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: true }); + return true; + } + + /** + * Install a field, first one wins. + * + * `lattice()` hands its typed arrays straight out and `terrain.ts` keeps the + * reference, so replacing a field that is already in use would leave the mesh + * reading one lattice and the minimap another. The two would in fact agree — + * the build is deterministic — which is exactly what makes the bug the kind + * you find six months later. + */ + private adopt(field: Field): void { + if (!this.state) this.state = field; + } + + /** + * The field, building it here and now if nobody awaited `ready()`. + * + * Sampling before ready is a bug in the caller, and this deliberately does + * not throw for it. The whole point of the Worker is to stop the main thread + * freezing; a thrown error would stop the map existing, which is a strictly + * worse failure and one that a self-hoster would hit on the very path — no + * Worker available — that the fallback exists to cover. So it warns once, + * loudly enough to find in a console, and builds. + */ + private ensureField(): Field { + if (this.state) return this.state; + warnSampledEarly(this.city.id, this.pending !== null); + const field = computeField(this); + this.adopt(field); + return field; } /** @@ -263,31 +390,14 @@ export class World { * spacing is no longer uniform and a consumer cannot recover it from * `minLat + i * cellLat` any more. */ - lattice(): { - latSteps: number; - lngSteps: number; - lats: Float64Array; - lngs: Float64Array; - height: Float32Array; - land: Uint8Array; - } { - const { height, land } = this.buildField(); - return { - latSteps: this.latSteps, - lngSteps: this.lngSteps, - lats: this.lats as Float64Array, - lngs: this.lngs as Float64Array, - height, - land, - }; + lattice(): Field { + return this.ensureField(); } /** Elevation in metres, bilinearly sampled from the cached lattice. */ elevationSampled(lat: number, lng: number): number { - const { height } = this.buildField(); - const lats = this.lats as Float64Array; - const lngs = this.lngs as Float64Array; - const w = this.lngSteps + 1; + const { lats, lngs, lngSteps, height } = this.ensureField(); + const w = lngSteps + 1; const i = cellIndex(lats, lat); const j = cellIndex(lngs, lng); @@ -311,6 +421,291 @@ export class World { groundAt(lat: number, lng: number): number { return this.metres(this.elevationSampled(lat, lng)); } + + /** + * A yes/no mask read back off the lattice, with the exact polygon test run + * only where the lattice cannot answer. + * + * This is the boolean half of what `elevationSampled` already does for + * height, and it exists for the same measured reason. `blocks.ts` asks about + * ~186k candidate lots on the Bay Area board, each of which walked every edge + * of every landmass and every park: 240 ms of the boot's main thread, on a + * desktop, for two facts the Worker had already established across the whole + * lattice. Sampling instead costs two binary searches and four byte loads — + * 19 ms for the same 186k lots, measured. + * + * The rule is **unanimity, or ask properly**. Four corners that agree decide + * the cell; a cell that straddles an edge falls through to `exact`, so the + * coastline and the park boundaries are answered by the polygons that define + * them and nothing is quantised where quantising would show. On the Bay Area + * that fallback fires for 532 of 186k lots, and the placement it produces + * differs from the exhaustive answer by eight buildings in 185,036. + * + * Unanimity is also the *more* correct answer inside a cell, not a + * concession. `terrain.ts` already emits a quad only where all four corners + * are land, so a lot that the exhaustive test called land inside a cell the + * terrain skipped was a building standing on no ground at all. This makes the + * two agree by construction. + */ + private sampled( + pick: (field: Field) => Uint8Array, + lat: number, + lng: number, + exact: (lat: number, lng: number) => boolean, + ): boolean { + const field = this.ensureField(); + const { lats, lngs, lngSteps } = field; + const i = cellIndex(lats, lat); + const j = cellIndex(lngs, lng); + // Off the board entirely. The lattice has no opinion and the polygons do. + if (i < 0 || j < 0) return exact.call(this, lat, lng); + const mask = pick(field); + const w = lngSteps + 1; + const k = i * w + j; + const votes = (mask[k] ?? 0) + (mask[k + 1] ?? 0) + (mask[k + w] ?? 0) + (mask[k + w + 1] ?? 0); + if (votes === 4) return true; + if (votes === 0) return false; + return exact.call(this, lat, lng); + } + + /** `isLand`, read off the lattice. See `sampled` for what that costs and buys. */ + isLandSampled(lat: number, lng: number): boolean { + return this.sampled(landOf, lat, lng, this.isLand); + } + + /** `inPark`, read off the lattice. See `sampled`. */ + inParkSampled(lat: number, lng: number): boolean { + return this.sampled(parkOf, lat, lng, this.inPark); + } +} + +// Module-level so `sampled`'s two callers pass one stable function each rather +// than allocating a closure per lookup, which at 186k lookups per board is the +// difference between this optimisation and a different kind of garbage. +const landOf = (field: Field): Uint8Array => field.land; +const parkOf = (field: Field): Uint8Array => field.park; + +// ---- Producing a field ----------------------------------------------------- + +/** + * The heightfield, from scratch. The expensive thing this whole module is + * arranged around. + * + * `elevationAt` is not cheap — every hill, four octaves of noise, and a + * distance-to-polygon per landmass. The terrain mesh wants it at hundreds of + * thousands of lattice points, and then every building, road sample and camera + * target wants it again. Computed once, read back bilinearly. + * + * A free function taking a `World` rather than a method, because the Worker + * runs exactly this code against a `World` it built from the cloned `City`. + * Sharing the function is what stops the off-thread and on-thread paths drifting + * into two subtly different maps — and they would drift, because nobody looks at + * the fallback. + * + * `onRow` fires once per lattice row and must be cheap; the Worker uses it to + * throttle its progress messages, and the main-thread fallback ignores it, + * since nothing can observe progress on a thread it is blocking. + */ +export function computeField(world: World, onRow?: (done: number, rows: number) => void): Field { + const { bounds, cellLat, cellLng } = world.city; + const coarse = Math.max(1, world.city.coarseFactor ?? 1); + const regions = world.city.focusRegions ?? []; + + // Rectilinear but NOT uniform: fine spacing across any band that a focus + // region occupies, coarse everywhere else. See `buildAxis`. + const lats = buildAxis( + bounds.minLat, + bounds.maxLat, + cellLat, + cellLat * coarse, + regions.map((r) => [r.minLat, r.maxLat] as [number, number]), + ); + const lngs = buildAxis( + bounds.minLng, + bounds.maxLng, + cellLng, + cellLng * coarse, + regions.map((r) => [r.minLng, r.maxLng] as [number, number]), + ); + + const latSteps = lats.length - 1; + const lngSteps = lngs.length - 1; + const w = lngSteps + 1; + const rows = latSteps + 1; + const height = new Float32Array(rows * w); + const land = new Uint8Array(rows * w); + const park = new Uint8Array(rows * w); + for (let i = 0; i < rows; i++) { + const lat = lats[i] as number; + for (let j = 0; j <= lngSteps; j++) { + const lng = lngs[j] as number; + const k = i * w + j; + const onLand = world.isLand(lat, lng); + land[k] = onLand ? 1 : 0; + height[k] = onLand ? world.elevationAt(lat, lng) : 0; + // Only on land, and not merely as an optimisation: a park mask with 1s + // out in the bay would let `sampled` carry a coastal cell unanimously + // into a park that stops at the shore. + park[k] = onLand && world.inPark(lat, lng) ? 1 : 0; + } + onRow?.(i + 1, rows); + } + return { latSteps, lngSteps, lats, lngs, height, land, park }; +} + +// ---- The Worker ------------------------------------------------------------ + +/** What `terrain.worker.ts` sends back. Kept here so both ends see one type. */ +export type FieldMessage = + | { type: "progress"; done: number; rows: number } + | ({ type: "field" } & Field) + | { type: "failed"; message: string }; + +/** What it is sent. */ +export interface FieldRequest { + city: City; +} + +/** + * `new Worker(new URL(...), { type: "module" })` is spelled out inline because + * that literal form is what Vite pattern-matches to emit the worker chunk. A + * variable holding the URL builds clean and 404s in production. + */ +function spawnFieldWorker(): Worker | null { + if (typeof Worker === "undefined") return null; + try { + return new Worker(new URL("./terrain.worker.ts", import.meta.url), { type: "module" }); + } catch { + // `file://` and some CSPs throw here rather than firing `onerror`. + return null; + } +} + +/** + * Longest silence tolerated from a worker before it is written off. + * + * Not a build budget — the worker reports progress about eight times a second, + * so on a slow phone taking twelve seconds over SoCal this never comes close to + * firing. It is a liveness check, and it exists for the one failure the Worker + * API gives you no event for: a browser reclaiming a worker under memory + * pressure. No `error`, no `messageerror`, nothing. Without this, the boot card + * stays up forever and the map never arrives, which is precisely the outcome + * the fallback path is supposed to make impossible. + */ +const WORKER_SILENCE_MS = 10_000; + +/** + * Drive one worker to completion, or give up on it. + * + * Resolves rather than rejects in every case, including the ones that are + * genuinely wrong, because the caller's answer to all of them is the same: fall + * back and carry on. What differs is how loud we are about it on the way past. + */ +function runInWorker( + worker: Worker, + city: City, + signal: AbortSignal | undefined, + onProgress: ((progress: FieldProgress) => void) | undefined, +): Promise { + return new Promise((resolve) => { + let settled = false; + // `ReturnType` rather than `number`: this repo has `@types/node` in the + // tree for the server workspace, which makes the global `setTimeout` the + // Node one at type-check time even in browser code. + let watchdog: ReturnType | undefined; + const finish = (result: Field | "failed" | "abandoned") => { + if (settled) return; + settled = true; + clearTimeout(watchdog); + signal?.removeEventListener("abort", abandon); + // Terminate rather than let it finish and ignore the answer. An abandoned + // Bay Area build is most of a second of a core that the city being + // switched *to* wants for itself. + worker.terminate(); + resolve(result); + }; + const abandon = () => finish("abandoned"); + signal?.addEventListener("abort", abandon, { once: true }); + + const heard = () => { + clearTimeout(watchdog); + watchdog = setTimeout(() => { + console.warn( + `Tera: heightfield worker went silent for ${WORKER_SILENCE_MS} ms; ` + + `building on the main thread`, + ); + finish("failed"); + }, WORKER_SILENCE_MS); + }; + heard(); + + worker.onmessage = (event: MessageEvent) => { + heard(); + const message = event.data; + if (message.type === "progress") { + onProgress?.({ + phase: "heightfield", + fraction: message.rows > 0 ? message.done / message.rows : 0, + onMainThread: false, + }); + return; + } + if (message.type === "field") { + onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: false }); + const { latSteps, lngSteps, lats, lngs, height, land, park } = message; + finish({ latSteps, lngSteps, lats, lngs, height, land, park }); + return; + } + console.warn( + `Tera: heightfield worker failed (${message.message}); building on the main thread`, + ); + finish("failed"); + }; + // Fires for a module that will not load at all — a CSP that permits + // `worker-src` but not the script, an offline reload against a stale cache + // — as well as for anything thrown inside it. + worker.onerror = () => { + console.warn("Tera: heightfield worker did not start; building on the main thread"); + finish("failed"); + }; + worker.onmessageerror = () => finish("failed"); + + try { + worker.postMessage({ city } satisfies FieldRequest); + } catch (err) { + // A `City` is pure data by contract — see `types.ts` — and structured + // clone is how that contract is enforced at runtime. If this throws, + // something has put a function, a class instance or a DOM node in a city + // pack, and the fix is to take it back out, not to JSON round-trip it + // here and lose whatever it was. + console.error( + `Tera: city "${city.id}" is not structured-cloneable, so its heightfield ` + + `cannot be built off the main thread. A city pack must be pure data.`, + err, + ); + finish("failed"); + } + }); +} + +/** Two frames, which is the shortest wait that straddles a paint. */ +function nextPaint(): Promise { + if (typeof requestAnimationFrame !== "function") return Promise.resolve(); + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +let warnedEarly = false; + +function warnSampledEarly(cityId: string, building: boolean): void { + if (warnedEarly) return; + warnedEarly = true; + console.warn( + `Tera: the heightfield for "${cityId}" was sampled before \`await world.ready()\`` + + (building ? " and while a worker was already building it" : "") + + `, so it was built on the main thread instead. This is a bug in the caller.`, + ); } // ---- Variable-resolution lattice ------------------------------------------ diff --git a/src/main.ts b/src/main.ts index 086d588..b2528de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,21 +12,47 @@ * the traffic is simulated, the office is a data file. Clone it and it works. */ -import { createAtmosphere, observe, PACIFIC_MARINE_LAYER } from "./engine/atmosphere.ts"; +import { + createAtmosphere, + observe, + PACIFIC_MARINE_LAYER, + type WeatherObservation, +} from "./engine/atmosphere.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; -import { SimulatedFlights } from "./engine/flights.ts"; +import { regionOf, SimulatedFlights } from "./engine/flights.ts"; +import type { Pose } from "./engine/scenekit.ts"; +import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; import type { City, Marker, MarkerPalette, View } from "./engine/types.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; -import { createTeraClient } from "./adapters/http.ts"; -import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts"; -import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts"; +import { + createTeraClient, + describeLiveness, + type TrafficSource, + type WeatherWatch, +} from "./adapters/http.ts"; +import { SAMPLE_MARKERS, SAMPLE_PALETTE, sampleRoutesFor } from "./adapters/sample.ts"; import { authFetch } from "./session.ts"; -import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; -import { MaterialRegistry } from "./assets/materials.ts"; +/** + * Three type-only imports and not one value among them, which is what keeps the + * office and the instruments out of the entry chunk. + * + * `import type` is erased before Rollup ever sees it, so none of these three + * files is an edge in the module graph and none of them lands in the 780 kB + * everybody downloads. The office arrives through the `await import()` in + * `loadOffice()`, the tools through the one in `boot()`, and the rule that says + * so for `src/tools/` is written out at the top of `tools/index.ts`. Turning any + * of these into a value import silently undoes the split, and nothing fails — + * the bundle just gets big again. + */ +import type { OfficeScene } from "./interiors/officeScene.ts"; +import type { Office } from "./interiors/types.ts"; +import type { MaterialRegistry } from "./assets/materials.ts"; +import type { Godmode, GodmodePlace } from "./tools/index.ts"; +import type { PoseEditor } from "./tools/poseEditor.ts"; const CITIES: { id: string; label: string; city: City }[] = [ { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, @@ -36,6 +62,24 @@ const CITIES: { id: string; label: string; city: City }[] = [ const canvas = document.querySelector("#scene"); if (!canvas) throw new Error("#scene canvas missing"); +/** + * One renderer, one loop, for as long as this page is open. + * + * Built here rather than inside `createScene` because a `WebGLRenderer` is a + * property of the *canvas* and not of the city drawn on it. When each city + * built its own, every switch between the Bay Area and SoCal abandoned a + * renderer on the one GL context this page has, and abandoned renderers do not + * give their textures back: `WebGLRenderer.dispose()` frees no texture at all, + * so ten switches measured 88 live GPU textures against zero `deleteTexture` + * calls, 16.8 MB of orphaned shadow map at a time. `stage.ts` has the numbers + * and the reading of three's source that they come from. + * + * Nothing disposes this. It outlives every city and every office on the page, + * and the page unload takes it — the same arrangement, and the same reasoning, + * as `officeMaterials` below. + */ +const stage = createStage(canvas); + // `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers // 404 rather than 403 to anyone who may not see it) is requested as the signed-in // viewer. On a `password`-mode or open deployment it is an ordinary fetch. @@ -43,6 +87,20 @@ const tera = createTeraClient({ fetch: authFetch }); let city: SceneHandle | null = null; let cityId = "sf"; +/** + * The city the user last *asked* for, which is not the same as the one that is + * mounted or even the one that is being built. + * + * `building()` defers its work by two animation frames so the boot card can + * paint, and a frame under load is not 16 ms — measured at 250 ms on a software + * rasteriser. Clicking SoCal and then changing your mind inside that window + * used to hit `if (id === cityId) return` against a `cityId` the deferred + * `mountCity` had not written yet, so the second click was discarded as + * redundant and you arrived at the city you had just cancelled. The guard has + * to be against the intention, and the intention is recorded synchronously in + * the click handler. + */ +let wantedCity = "sf"; let office: OfficeScene | null = null; let inside = false; let markers: Marker[] = SAMPLE_MARKERS; @@ -64,19 +122,49 @@ let access: Access = { subject: null, signInUrl: null, can: capabilitiesFor("anon"), + feeds: null, }; let atmosphere: ReturnType | null = null; let minimap: Minimap | null = null; /** - * One texture set for every office this page ever builds. + * The sky over the city currently on screen, polled while it is on screen. * + * One watch at a time and it belongs to the board, not to the page. Stopping it + * at the top of `mountCity` is the whole of the cancel-on-switch rule: a + * `/weather` request for San Francisco that lands after the user has moved to + * SoCal would otherwise put the marine layer over Long Beach, and it is a + * request in flight for most of the second in which somebody clicks. + */ +let weatherWatch: WeatherWatch | null = null; +/** + * The live traffic source, when there is one, kept so the corner label can ask + * it whether the aircraft on screen were observed. `null` means the simulator, + * which is never live and does not need asking. + */ +let cityFlights: TrafficSource | null = null; +/** + * The build in progress. Aborting it is what makes a second click on the other + * city cheap: `createScene` drops the heightfield, resolves `null`, and has + * allocated no WebGL context for the abandoned board. + */ +let mounting: AbortController | null = null; +let godmode: Godmode | null = null; +let poseEditor: PoseEditor | null = null; +/** + * The office, once somebody has asked for it. Both are `null` until the first + * `loadOffice()` because both live in a chunk this page does not fetch until + * then — see `loadOffice` for the arithmetic, and the import block above for + * what keeps them out of the entry chunk. + * + * The registry is one texture set for every office this page ever builds. * Signing in while standing in the public office is a `dispose()` and a second * `createOfficeScene` at `"full"` — cheap only if both are handed the same * registry, because drawing the textures is the expensive part and a registry * draws them once. Owned here and disposed nowhere: it outlives every scene * that borrows it, and the page teardown takes the process with it. */ -const officeMaterials = new MaterialRegistry({ quality: "high" }); +let officePack: Office | null = null; +let officeMaterials: MaterialRegistry | null = null; /** * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind @@ -93,24 +181,50 @@ const OPENS_IN_OFFICE = // ---- Time ----------------------------------------------------------------- /** - * `null` follows the wall clock. The scrubber exists because the honest answer + * `null` follows the wall clock. The override exists because the honest answer * at 2 a.m. is a very dark city — correct, and not what you want to be looking * at while judging whether the sun is in the right place. + * + * A whole `Date` and not an hour, which is the change the godmode panel forced + * and the right shape anyway. The old scrubber wrote an hour onto *today*, so + * there was no way to ask for the December solstice, and any control that could + * set a date would have had it silently discarded on the next scrub. One + * override, one writer, one type that can carry everything the sun depends on. */ -let hourOverride: number | null = null; +let instantOverride: Date | null = null; +/** + * A fabricated sky, or `null` for whatever the deployment reports. + * + * Kept next to the instant because it is the same kind of thing — a god-only + * lie about the inputs, told to see what the renderer does with it — and it + * takes precedence over the live observation for exactly as long as it is set. + */ +let weatherOverride: WeatherObservation | null = null; function currentInstant(): Date { - const now = new Date(); - if (hourOverride === null) return now; - const d = new Date(now); - d.setHours(Math.floor(hourOverride), Math.round((hourOverride % 1) * 60), 0, 0); - return d; + return instantOverride ?? new Date(); +} + +/** + * What the sky is doing, in the order the answers are trusted. + * + * The override wins because somebody typed it. Otherwise the live observation, + * and `null` — nobody was asked — when there is no watch or it has not landed + * yet. `null` is not "clear": `atmosphere.ts` treats a *reported* clear sky as + * authority that suppresses the modelled marine layer, so handing it an + * invented clear day on every failed poll would permanently kill San + * Francisco's fog on the zero-config box where the local model is all there is. + * See `WeatherFeed` in `adapters/http.ts`, which is careful about the same + * distinction from the other side. + */ +function currentWeather(): WeatherObservation | null { + return weatherOverride ?? weatherWatch?.current().value ?? null; } function updateSun() { const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; if (!city || !atmosphere) return; - const env = observe(active.center.lat, active.center.lng, currentInstant()); + const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); city.setLighting(atmosphere.apply(env)); city.setSolarElevation(env.sun.elevation); // The plan view follows the same day the map does. It computes its own @@ -135,29 +249,106 @@ function updateSun() { * are coming straight back — nobody flips between metros often enough to * justify holding two heightfields and 140k building instances at once. */ -function mountCity(id: string) { +async function mountCity(id: string) { const entry = CITIES.find((c) => c.id === id); if (!entry || !canvas) return; + /** + * Abandon whatever is still building before touching anything else. + * + * Two clicks on the city buttons a second apart used to run two heightfields + * to completion and race to assign `city`; now the first `createScene` sees + * its signal go and resolves `null` without ever allocating a renderer. The + * controller is replaced rather than reused because an aborted signal stays + * aborted, and the new build must not be born cancelled. + */ + mounting?.abort(); + const mount = new AbortController(); + mounting = mount; + + wantedCity = id; + weatherWatch?.stop(); + weatherWatch = null; + poseEditor?.destroy(); + poseEditor = null; office?.dispose(); office = null; inside = false; minimap?.dispose(); minimap = null; city?.dispose(); + // Not merely tidy. `city` is read by the frame pump, by `updateSun` and by + // every render function, and the gap between the dispose above and the + // assignment below is now an `await` wide rather than a statement — long + // enough for all three to run against a torn-down scene. + city = null; cityId = id; - city = createScene(canvas, { + + /** + * The sky and the traffic are per-city and are chosen here, before the build, + * because `flights` is fixed at scene construction. + * + * Two gates, and both are needed. `can.liveData` is the tier — an anonymous + * visitor must not be firing requests the server is going to refuse — and + * `feeds` is the deployment, which is what stops a member on the ordinary + * box, where every source is `none`, from polling two endpoints forever for + * a 404. The old code gated the flights on `liveData`, the markers flag, + * which is a different feed entirely: a deployment with a real ADS-B receiver + * and no marker file flew the simulator. + */ + const region = regionOf(entry.city); + // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed + // unconditionally and all of it is over San Francisco, so the SoCal board's + // entire sky projected ~590 km off the world and rendered as nothing at all. + const routes = sampleRoutesFor(entry.city); + const traffic = + access.can.liveData && access.feeds?.flights ? tera.flights(region, routes) : null; + cityFlights = traffic; + + const handle = await createScene(stage, { city: entry.city, markerPalette: palette, - // `liveData` alone is not enough: it only records that a feed answered - // once, at boot, before the tier was known. An anonymous visitor asking for - // live traffic gets an empty sky rather than the simulation, which looks - // like a broken layer instead of an honest one. - flights: - access.can.liveData && liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES), + flights: traffic ?? new SimulatedFlights(routes), onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null), + signal: mount.signal, + // An abandoned build keeps its worker running for a tick or two after the + // abort; its percentages must not land on the card the new city is using. + onProgress: (p) => { + if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread); + }, }); + if (!handle) { + /** + * Superseded. `scene.dispose()` is what normally cancels the traffic + * source, and there is no scene — so the polling this call started would + * otherwise outlive the board it was started for, and keep a request in + * the air for a city nobody is looking at. + */ + traffic?.dispose(); + if (cityFlights === traffic) cityFlights = null; + return; + } + city = handle; + + /** + * The weather, started only now that the board exists. + * + * Deliberately after the build rather than beside it: a poll issued at the + * top of a two-second heightfield is a request for a city the user may + * already have left, and the first thing `stop()` would do is throw the + * answer away. Nothing on screen is waiting for it — `updateSun` runs + * immediately with `null`, which is climatology, and the observation + * replaces it when it lands. + */ + weatherWatch = + access.can.liveData && access.feeds?.weather + ? tera.watchWeather(entry.city.center, () => { + updateSun(); + renderSource(); + }) + : null; + // Fog distances are scene units, so they have to follow the board — 210/460 // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans @@ -209,6 +400,10 @@ function mountCity(id: string) { camera: city.stageScene.camera, controls: city.stageScene.controls, markerPalette: palette, + // The plan view is a 2D canvas the same size as a phone's thumb, and on a + // handheld it is drawn at the same ceiling the WebGL renderer uses. One + // definition of "phone", in `stage.ts`, read by both. + maxPixelRatio: deviceProfile().maxPixelRatio, onSeek(lat, lng) { if (!city) return; /** @@ -241,6 +436,12 @@ function mountCity(id: string) { minimapFrame?.replaceChildren(minimap.canvas); minimap.setMarkers(id === "sf" ? markers : []); + // The instruments, for the one visitor in a deployment who has them. The pose + // editor holds a `World`, a camera and a controls, so it belongs to the board + // and dies with it — the same reason the plan view does. + mountPoseEditor(city); + refreshGodmodePlace(); + updateSun(); renderLegend(); } @@ -258,8 +459,31 @@ function mountCity(id: string) { requestAnimationFrame(function pumpMinimap() { requestAnimationFrame(pumpMinimap); minimap?.tick(); + // The pose editor is on the same pump for the same reasons, and is `null` for + // everyone who is not god, so this is one property read per frame on a + // public page. + poseEditor?.tick(); + pollLiveness(); }); +/** + * Whether the corner label is still telling the truth, once a second. + * + * The two live feeds settle on their own schedule and neither has an event to + * subscribe to: `TrafficSource.live()` flips when a `/flights` body lands + * inside the region, which is somewhere in the first fifteen seconds, and the + * weather watch fires its own callback but only when a *poll* settles. A + * one-second sample is late by nothing anybody can perceive and costs a + * subtraction on the frames it skips. + */ +let livenessCheckedAt = 0; +function pollLiveness() { + const now = performance.now(); + if (now - livenessCheckedAt < 1000) return; + livenessCheckedAt = now; + renderSource(); +} + // ---- Office --------------------------------------------------------------- /** @@ -274,15 +498,21 @@ requestAnimationFrame(function pumpMinimap() { * refuses occupancy to an anonymous caller, not because this function declined * to draw it. */ -function enterOffice() { +async function enterOffice() { if (!city) return; if (!office) { + const built = await loadOffice(); + // The chunk arrived after the user had already left for the other city, or + // it did not arrive at all. Either way there is no room to walk into and + // `loadOffice` has already said so on the button. + if (!built || !city) return; + const { createOfficeScene, pack, materials } = built; const depth = access.can.officeDepth; - office = createOfficeScene(LUMBRIDGE_HQ, { + office = createOfficeScene(pack, { dom: city.stage.renderer.domElement, background: 0x11161c, depth, - materials: officeMaterials, + materials, // Two different questions, so two different callbacks. `onPresencePick` // answers "who is at this desk"; `onPlacePick` answers only "this is a // desk, and it is the fourteenth one" — which is all a stranger is told. @@ -295,6 +525,7 @@ function enterOffice() { city.stage.setScene(office); inside = true; showDetail(null); + refreshGodmodePlace(); renderLegend(); } @@ -303,9 +534,63 @@ function leaveOffice() { city.stage.setScene(city.stageScene); inside = false; showDetail(null); + refreshGodmodePlace(); renderLegend(); } +/** + * Fetch Spaces. + * + * The office is the largest thing in this build that most visitors never open: + * the interior, the furniture catalogue, the material registry and the + * floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be + * downloaded, parsed and executed on every load of a map page by people who + * came to look at a city. Behind these three `await import()`s Vite gives them + * chunks of their own and the door fetches them on the way through. Measured, + * entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after — + * the difference is smaller than the chunks because three.js is shared and + * stays where it was. + * + * All three in one `Promise.all` because they are one arrival: the pack without + * the builder is a data file nobody can draw, so the fetches overlap rather + * than queue. Rollup happens to emit them as three chunks the browser asks for + * together; awaiting them in sequence would make that three round trips on a + * slow link for no reason at all. + * + * There is deliberately no retry and no cache-busting. A failed chunk fetch is + * a deploy that moved the file under an open tab; the honest answer is to say + * the door did not open and let the next click try again, which it will, + * because a rejected dynamic import is not memoised by the browser. + */ +async function loadOffice(): Promise<{ + createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene; + pack: Office; + materials: MaterialRegistry; +} | null> { + try { + const [interiors, pack, assets] = await Promise.all([ + import("./interiors/officeScene.ts"), + import("./offices/lumbridge-hq.ts"), + import("./assets/materials.ts"), + ]); + officePack ??= pack.default; + officeMaterials ??= new assets.MaterialRegistry({ quality: "high" }); + return { + createOfficeScene: interiors.createOfficeScene, + pack: officePack, + materials: officeMaterials, + }; + } catch { + showDetail("The office did not load. Check the connection and try the door again."); + return null; + } +} + +/** The office's name, for the two bits of chrome that say where you are. */ +function officeName(): string { + return officePack?.name ?? "Spaces"; +} + // ---- Chrome --------------------------------------------------------------- const nav = document.querySelector("#chapters"); @@ -323,12 +608,17 @@ const panelToggle = document.querySelector("#panel-toggle"); const panelToggleLabel = document.querySelector("#panel-toggle-label"); const shortcutsCard = document.querySelector("#shortcuts"); const helpButton = document.querySelector("#help"); +const planToggle = document.querySelector("#plan-toggle"); +const credits = document.querySelector("#credits"); function showDetail(text: string | null) { const card = document.querySelector("#detail"); - if (!card) return; + const body = document.querySelector("#detail-text"); + if (!card || !body) return; card.hidden = text === null; - card.textContent = text ?? ""; + // The text, and not the card: the card also holds the dismiss button, which + // `textContent` on the card would delete the first time a marker was picked. + body.textContent = text ?? ""; } function renderCityPicker() { @@ -373,7 +663,7 @@ function renderLegend() { blurb.hidden = !active?.description; } const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? ""; - if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : cityLabel; + if (title) title.textContent = inside ? officeName() : cityLabel; if (subtitle) { subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; } @@ -382,32 +672,13 @@ function renderLegend() { // what is behind it, and that is the badge's job to say, not the button's. enterButton.textContent = inside ? "← Back to the city" : "Enter the office →"; } - if (source) { - /** - * Only the *positive* case gets a permanent corner label. - * - * This used to read "sample data · fabricated, not real companies" on every - * frame of every load, which is the overwhelmingly common case — no - * deployment has a markers source wired by default — so the disclosure was - * on screen approximately always and had become furniture. A caption nobody - * reads is not disclosure, it is a watermark. - * - * The fact still has to be somewhere on the same screen as the map, so it - * is stated on the boot card everyone passes through and again in the `?` - * card, one keypress away and permanently reachable. What is left here is - * the rare, genuinely informative signal: *this* map is showing live data, - * which is a thing worth interrupting someone to say. - */ - source.textContent = liveData ? "live data" : ""; - source.hidden = !liveData; - source.className = "source live"; - } + renderSource(); if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel; if (canvas) { canvas.setAttribute( "aria-label", inside - ? `${LUMBRIDGE_HQ.name}, seen from above. Drag to orbit, scroll to zoom.` + ? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.` : `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`, ); } @@ -415,6 +686,88 @@ function renderLegend() { renderOfficeBadge(); } +/** + * The corner label, which now names the parts rather than claiming the whole. + * + * Only the *positive* case gets a permanent label. This used to read "sample + * data · fabricated, not real companies" on every frame of every load, which is + * the overwhelmingly common case — no deployment has a markers source wired by + * default — so the disclosure was on screen approximately always and had become + * furniture. A caption nobody reads is not disclosure, it is a watermark. The + * fact still has to be somewhere on the same screen as the map, so it is stated + * on the boot card everyone passes through and again in the `?` card, one + * keypress away and permanently reachable. + * + * What is left is the informative signal, and it is three signals rather than + * one. The markers, the weather and the traffic arrive from three different + * places and every combination of them is a deployment that exists; a single + * flag has to pick one to be about and then lie about the other two. The + * particular lie this closes is "live data" printed over invented companies + * because a weather station answered — which is precisely the claim the `live` + * flag was introduced to prevent. `describeLiveness` in `adapters/http.ts` owns + * the wording; all three live is the only case that still says "live data". + * + * Called from `renderLegend` and once a second from the frame pump, because the + * feeds settle after the legend has been drawn. + */ +function renderSource() { + if (!source) return; + const label = describeLiveness({ + markers: liveData, + // An override is a sky somebody invented, so it retires the claim for as + // long as it is up — the label is about what is on screen, not about what + // the deployment could have shown. + weather: weatherOverride === null && (weatherWatch?.current().live ?? false), + flights: cityFlights?.live() ?? false, + }); + if (source.textContent !== label) source.textContent = label; + source.hidden = label === ""; + /** + * The green. `.source.live` in `index.html` is the whole visual difference + * between this line and the rest of the chrome, and the rewrite that replaced + * `source.className = "source live"` with a `textContent`/`hidden` pair + * dropped it — so every live label rendered at `--ink-3`, the same muted grey + * as a key hint, and the stylesheet rule could no longer match anything. This + * label is only ever on screen when it has something to say; the colour is + * how it says it is worth reading. + */ + source.classList.toggle("live", label !== ""); + renderCredits(); +} + +/** + * Who to thank for what is on screen, in the `?` card. + * + * MET Norway and Open-Meteo publish under CC BY 4.0 and the server emits the + * credit line each of them asks for — `server/README.md` says in as many words + * that the consumer is expected to display it — and adsb.lol asks to be named + * for the positions. All of it arrived, was parsed into `WeatherFeed.attribution` + * and `FlightsBody.attribution`, and was then read by nobody: a licence + * obligation plumbed to within one line of being met. + * + * It goes in the `?` card rather than on the `#source` line, and that is a + * choice rather than convenience. The corner label is one short phrase and on a + * phone it is explicitly clamped to a single ellipsised line, so a licence + * sentence appended to it would be *truncated* — the one outcome worse than + * putting it a keypress away. The `?` card is reachable from every state the + * app can be in, on both layouts, and already carries the sentence about the + * markers being fabricated; the provenance of the map belongs in one place. + * + * Empty when nothing live is on screen, because a credit for data nobody is + * looking at is noise, and because the zero-config build owes nobody anything. + */ +function renderCredits() { + if (!credits) return; + const lines: string[] = []; + // The weather override is somebody's invention; it is not MET Norway's sky + // and must not be attributed to them. + if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? [])); + lines.push(...(cityFlights?.attribution() ?? [])); + const unique = [...new Set(lines.filter((line) => line !== ""))]; + credits.textContent = unique.join(" · "); + credits.hidden = unique.length === 0; +} + /** * The one thing a public visitor is actually missing, said in the place where * they would notice it missing. @@ -446,7 +799,7 @@ function renderOfficeBadge() { * Who the site thinks you are, in the corner, always. Three words and a name. * * It is here rather than buried in a menu because every other difference on - * this page — an empty office, sample markers, a missing scrubber — is a + * this page — an empty office, sample markers, no godmode tab — is a * *silence*, and a silence you cannot attribute is indistinguishable from a * fault. This is the line that tells you which of the two you are looking at. */ @@ -497,29 +850,66 @@ function flyToIndex(index: number) { function switchCity(id: string) { if (inside) leaveOffice(); - if (id === cityId) return; + if (id === wantedCity) return; + wantedCity = id; const label = CITIES.find((c) => c.id === id)?.label ?? id; void building(`Building ${label}…`, () => mountCity(id)); } function stepCity(delta: number) { - const at = CITIES.findIndex((c) => c.id === cityId); + const at = CITIES.findIndex((c) => c.id === wantedCity); const next = CITIES[(at + delta + CITIES.length) % CITIES.length]; if (next) switchCity(next.id); } -function toggleOffice() { +/** + * Guards the door against the second click. + * + * Entering now begins with a network fetch for the Spaces chunk, so the window + * between the click and the room is wide enough to click in again — and two + * `enterOffice()` calls in that window build two office scenes, park the second + * on the stage and leak the first, textures and all. One flag, cleared in a + * `finally` so a failed fetch does not wedge the door shut. + */ +let entering = false; + +async function toggleOffice() { if (inside) { leaveOffice(); return; } - // Only the first entry builds anything; after that the office is parked in - // memory next to the paused city and the swap is a pointer. - if (office) enterOffice(); - else void building("Building the office…", () => enterOffice()); + if (entering) return; + // Only the first entry fetches or builds anything; after that the office is + // parked in memory next to the paused city and the swap is a pointer. + if (office) { + void enterOffice(); + return; + } + entering = true; + /** + * Say so on the button before anything else happens. + * + * The boot card comes up too, but it comes up on the *next* frame at the + * earliest, and on a slow connection the chunk is the long pole rather than + * the build. A door that does nothing visible for half a second gets clicked + * again; a door that says "Opening…" gets waited for. + */ + if (enterButton) { + enterButton.textContent = "Opening the office…"; + enterButton.setAttribute("aria-busy", "true"); + } + try { + await building("Fetching the office…", () => enterOffice()); + } finally { + entering = false; + enterButton?.removeAttribute("aria-busy"); + // `renderLegend` writes the real label whichever way it went — "← Back to + // the city" if we are in, the door again if the fetch failed. + renderLegend(); + } } -enterButton?.addEventListener("click", () => toggleOffice()); +enterButton?.addEventListener("click", () => void toggleOffice()); // ---- Panels, plan and overlays ---------------------------------------------- @@ -541,6 +931,30 @@ function applyPanel() { function applyPlan() { document.body.classList.toggle("minimap-off", !planOpen); + planToggle?.setAttribute("aria-pressed", String(planOpen)); +} + +/** + * One body, two ways in, and the second one is the point. + * + * This lived inline in the `M` branch of the keydown handler and was reachable + * from nowhere else, which made the plan view **unreachable on any touch + * device**: `planOpen` is seeded `window.innerWidth > 600`, so a phone starts + * with it off, and a phone has no `M`. Every visible control at 390px was + * enumerated and none of them could turn it on. `index.html` has carried a + * designed phone layout for `.corner` — a bottom sheet above the rail, at + * `min(38dvh, 18rem)` — that no visitor to that layout could ever see, under a + * comment saying it "costs nothing until it is asked for". There was no way to + * ask. `#plan-toggle` is that way, shown wherever the pointer is coarse. + * + * So the key and the button call this, and `planChosen` is set by both for the + * same reason it always was: once somebody has an opinion, the viewport stops + * having one. + */ +function togglePlan() { + planOpen = !planOpen; + planChosen = true; + applyPlan(); } panelToggle?.addEventListener("click", () => { @@ -548,6 +962,21 @@ panelToggle?.addEventListener("click", () => { applyPanel(); }); +planToggle?.addEventListener("click", () => togglePlan()); + +/** + * The scrim behind the phone's panel sheet. It is `display: none` above 600px, + * so this listener is only ever reachable where the sheet exists. + */ +document.querySelector("#scrim")?.addEventListener("click", () => { + panelOpen = false; + applyPanel(); +}); + +document.querySelector("#detail-close")?.addEventListener("click", () => { + showDetail(null); +}); + window.addEventListener("resize", () => { if (!planChosen) { planOpen = window.innerWidth > 600; @@ -620,45 +1049,247 @@ window.addEventListener("keydown", (event) => { } const lower = event.key.toLowerCase(); if (lower === "m") { - planOpen = !planOpen; - planChosen = true; - applyPlan(); + togglePlan(); return; } - if (lower === "o") toggleOffice(); + if (lower === "o") void toggleOffice(); }); // ---- Time ------------------------------------------------------------------- -const scrubber = document.querySelector("#hour"); -scrubber?.addEventListener("input", () => { - if (!access.can.timeControl) return; - hourOverride = Number(scrubber.value); - updateSun(); -}); -document.querySelector("#now")?.addEventListener("click", () => { - if (!access.can.timeControl) return; - hourOverride = null; - if (scrubber) scrubber.value = String(new Date().getHours()); - updateSun(); -}); - /** - * The scrubber is an instrument, and instruments are god-only. The *clock* is - * not: a map that will not tell you what time it is showing is worse than one - * you cannot scrub, so `#clock` stays outside `#scrub` and stays visible to - * everyone. + * The `#hour` slider and its `now` button are gone, replaced rather than kept. * - * `hidden` rather than `disabled`, because a disabled slider is still a tab - * stop and still announces itself — an affordance offered and withdrawn in the - * same breath. Without the control there is no override, so the clock follows - * the wall clock, which is the honest default anyway. + * They were a second writer for one override, and the weaker of the two: + * `capabilitiesFor` hands `timeControl` and `debug` to exactly the same tier, so + * there was never an audience for the simple case — the only person who could + * see the scrubber was the same person who can open the godmode panel. Keeping + * both meant the slider wrote an hour onto *today* and silently discarded + * whatever date the panel had set, which is a bug with no upside. + * + * `#clock` stays exactly as it was, and stays visible to everyone: a map that + * will not say what time it is showing is worse than one you cannot scrub. + * + * What is left of the gate is one line, and it is belt and braces — the only + * writer of `instantOverride` is the panel, and the panel is not constructed + * unless `can.debug`. It stays because "no control" and "no override" are two + * different facts, and the second is the one the renderer depends on. */ function applyTimeControl() { - const scrub = document.querySelector("#scrub"); - if (scrub) scrub.hidden = !access.can.timeControl; - if (!access.can.timeControl) hourOverride = null; - if (scrubber) scrubber.value = String(new Date().getHours()); + if (!access.can.timeControl) instantOverride = null; +} + +// ---- Instruments ------------------------------------------------------------ + +/** + * The godmode panel and the pose editor, for the one visitor in a deployment + * who has them. + * + * **Constructed, not hidden.** Everything in this section is behind + * `access.can.debug`, and for a member or an anonymous visitor the result is + * not a panel with `display: none` on it — it is no element, no `