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 @@
-
+
+
+
+
+
+
@@ -607,15 +835,36 @@
[]
Previous / next city
O
Enter or leave the office
M
Show or hide the plan view
-
?
This card
+
?
This card
Esc
Close an overlay, or leave the office
Tab
Every control, in reading order
On the plan: click or drag to move the view, scroll to zoom,
arrow keys to aim and Enter to go.
+
Touch
+
+
One finger
Orbit
+
Two fingers
Pinch to zoom, drag to move over the ground
+
Tap a marker
Its card, at the bottom of the screen
+
Tap the map
Dismiss the card; tap the ☰ sheet's scrim to close it
+
Plan view
The button on the rail shows or hides it, as a sheet above the rail
+
+
The markers on this map are fabricated. No real company data
- ships in this build; a deployment wired to a live source says live data
- in the corner, and this one does not.
+ ships in this build. A deployment wired to a live source names it in the corner —
+ live weather, live traffic, live markers, or
+ live data when all three are. Nothing in the corner means everything you
+ are looking at came out of this bundle.
+
+
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 `