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:
+719
-69
@@ -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(" + ")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user