1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+719 -69
View File
@@ -11,19 +11,37 @@
*
* **Every call degrades instead of failing.** No server, a 404, a static host
* answering `/api/v1/markers` with its own index.html, a network that has gone
* away mid-session: all of it lands on the sample data in `sample.ts` and the
* synthetic clear day below, and the map keeps rendering. That is not defensive
* habit, it is the acceptance test the whole repo is held to — a stranger clones
* this, runs one command, and gets a city, with no account, no key and no
* network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
* away mid-session: all of it lands on the sample data in `sample.ts` and on a
* sky nobody claims to have observed, and the map keeps rendering. That is not
* defensive habit, it is the acceptance test the whole repo is held to — a
* stranger clones this, runs one command, and gets a city, with no account, no
* key and no network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
* working Tera; pointing it at a server is an upgrade, not a requirement.
*
* **Everything that is about a place takes the place as an argument.** Weather
* and traffic are both per-city and this build has two cities nearly six hundred
* kilometres apart, so a client that asked "what is the weather" without
* saying where would be asking the server to guess — and the server's guess is
* a single `TERA_ORIGIN_LAT/LNG` pair chosen at deploy time, which is right for
* at most one of them. The concrete failure is San Francisco's fog rolling over
* Long Beach; every location parameter and every relevance check below exists
* to make that impossible rather than unlikely.
*
* The wire types live in `src/server/wire.ts` and are types only, so importing
* them costs the bundle nothing.
*/
import type { WeatherObservation } from "../engine/atmosphere.ts";
import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts";
import {
distanceNm,
inRegion,
sampleRoute,
SimulatedFlights,
syntheticRoutes,
type Place,
type SimRoute,
type SkyRegion,
} from "../engine/flights.ts";
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
import { seededRandom } from "../engine/world.ts";
import type {
@@ -34,7 +52,7 @@ import type {
OfficeDoc,
WeatherBody,
} from "../server/wire.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const DEFAULT_BASE = "/api/v1";
@@ -45,6 +63,19 @@ const DEFAULT_TIMEOUT_MS = 4000;
/** How long to wait before trying the flights endpoint again after it fails. */
const RETRY_SECONDS = 30;
/**
* How long to wait before asking again for something the server answered about
* a different part of the world.
*
* Fifteen minutes, and it is a back-off rather than a give-up on purpose. A box
* pinned to one origin will keep answering about that origin for as long as it
* is configured that way, so polling it every TTL is spending a request on a
* body that gets thrown away — but the thing that changes the answer is a
* redeploy, which happens, and a client that stopped asking would need a reload
* to notice.
*/
const ELSEWHERE_SECONDS = 900;
export interface TeraApiOptions {
/**
* Base URL, with no trailing slash. Absolute is allowed and is what a
@@ -97,20 +128,82 @@ export interface MarkerFeed extends Feed<Marker[]> {
attribution: string[];
}
export interface WeatherFeed extends Feed<WeatherObservation> {
/**
* 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<WeatherObservation | null> {
/**
* 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<HealthBody | null>;
markers(): Promise<MarkerFeed>;
weather(): Promise<WeatherFeed>;
/**
* 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<WeatherFeed>;
/**
* 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<OfficeDoc | null>;
}
/**
* 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<string, string | number>;
signal?: AbortSignal;
}
type Get = <T>(path: string, options?: GetOptions) => Promise<T | null>;
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<T>(path: string): Promise<T | null> {
const get: Get = async <T,>(path: string, opts: GetOptions = {}): Promise<T | null> => {
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<HealthBody>("/health"),
@@ -189,43 +306,318 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
};
},
async weather(): Promise<WeatherFeed> {
const body = await get<WeatherBody>("/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<WeatherFeed> {
const body = await get<WeatherBody>("/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<OfficeDoc>(`/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<typeof setTimeout> | 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<void> {
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<WeatherBody>("/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<string, string> {
return { lat: at.lat.toFixed(2), lng: at.lng.toFixed(2) };
}
function queryString(query: Record<string, string | number> | 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: <T>(path: string) => Promise<T | null>,
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<FlightsBody>("/flights")
if (this.stopped || this.inFlight || now < this.nextFetchAt) return;
const controller = new AbortController();
this.inFlight = controller;
void this.get<FlightsBody>("/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(" + ")}`;
}