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
+16 -3
View File
@@ -35,16 +35,29 @@ it does not have to happen.
```ts
const tera = createTeraClient(); // same-origin /api/v1
const markers = await tera.markers();
const scene = createScene(canvas, {
const scene = await createScene(stage, {
city: SAN_FRANCISCO,
markerPalette: markers.palette,
flights: tera.flights(),
flights: tera.flights(regionOf(SAN_FRANCISCO), sampleRoutesFor("sf")),
});
scene.setMarkers(markers.value);
if (!markers.live) showSampleDataNotice();
```
Note the ordering: `markerPalette` is fixed when the scene is built, so the
Three things about that call are load-bearing.
`createScene` is **async** and takes a `Stage` rather than a canvas: the
heightfield is built in a Worker, and the renderer outlives any one city, so the
stage is created once for the page and handed to each scene in turn.
`flights` takes a **region**, not a client-wide origin. The Bay Area and SoCal
are six hundred kilometres apart and a single configured origin served one of
them and lied to the other. `regionOf(city)` derives it from the city's own
bounds, so a city pack added later needs no configuration to get its own sky.
The second argument is the simulated traffic to fly when the API has none, which
is what keeps the zero-config case from showing an empty sky.
And note the ordering: `markerPalette` is fixed when the scene is built, so the
markers have to be awaited first. `markers.palette` is the sample palette when
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
is real — the sample keys are not your keys.
+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(" + ")}`;
}
+52 -3
View File
@@ -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<City, "id" | "center" | "bounds">): SimRoute[] {
if (city.id === "sf") return SAMPLE_ROUTES;
if (city.id === "socal") return SAMPLE_SOCAL_ROUTES;
return syntheticRoutes(regionOf(city));
}