/** * The real adapter: markers, weather and traffic from the Tera API, with the * bundled sample data underneath it. * * This is the one place in the browser build that knows the API exists. The * engine takes `Marker[]`, a `WeatherObservation` and a `FlightSource` and has * no idea where any of them came from — that boundary is what lets one renderer * serve a private career map and a public sector map without either being a * fork (ARCHITECTURE.md §3.3), and it is why this file is an adapter rather than * a client scattered through the scene. * * **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 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 type { DeviceCommand, DeviceState } from "../devices/types.ts"; import { deviceStateSignature } from "../devices/types.ts"; import { aircraftDetail, distanceNm, inRegion, sampleRoute, SimulatedFlights, syntheticRoutes, type AircraftDetail, type Place, type SimRoute, type SkyRegion, } from "../engine/flights.ts"; import type { SatelliteElements } from "../engine/satellites.ts"; import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts"; import { seededRandom } from "../engine/world.ts"; import type { BirdsBody, DeviceCommandBody, DeviceCommandResultBody, DevicesBody, DevicesSourceId, FiresBody, FlightsBody, FlightsPlanBody, HealthBody, MarkersBody, OfficeDoc, PresenceBody, RadarBody, SatellitesBody, WeatherBody, WireAircraft, } from "../server/wire.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"; /** How long any one request may take before the fallback is used instead. */ 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 * self-hoster running the browser build and the API on different origins * wants; the default assumes they are the same origin. */ base?: string; /** Injected for tests. Absent means `globalThis.fetch`. */ fetch?: typeof fetch; timeoutMs?: number; /** * The palette live markers are coloured by. * * The engine looks `colorKey` up and the wire does not carry colours, so * somebody has to supply this and it cannot be the server: what a key *means* * is the consuming app's business. When the API is absent this is ignored and * `SAMPLE_PALETTE` is used instead, because sample keys are not the caller's * keys. */ palette?: MarkerPalette; } /** * A response, plus whether it is real. * * `live` is the field that stops a fallback from being a lie. A demo showing * invented companies and a deployment showing real ones must not look identical * to the code above them — the caller is expected to say so in the interface, * and cannot if the adapter quietly papers over the difference. */ export interface Feed { value: T; live: boolean; } export interface MarkerFeed extends Feed { /** The palette these markers are meant to be read with. */ palette: MarkerPalette; /** ISO-8601 snapshot time, or `null` for the sample set, which has no date. */ generatedAt: string | null; /** * Rows the server's public-shape gate refused, by reason and count. * * Passed through rather than swallowed. A gate that drops rows silently is * indistinguishable from an empty database, which is exactly the confusion * `MarkersBody.refused` exists to prevent — and it is the visible end of the * provenance rule in CONTRACT.md §8. */ refused: { reason: string; count: number }[]; attribution: string[]; } /** * The sky, or an admission that nobody knows what the sky is doing. * * `value` is nullable and that null is load-bearing rather than lazy. It is * exactly `Environment.weather` in `atmosphere.ts`, where `null` means "nobody * was asked" and lets the local climatology run, and a `WeatherObservation` * means somebody looked — which `apply` then treats as authority over the * model. So the type matches the argument it is destined for, the caller can * hand `feed.value` straight to `observe()`, and there is no shape in which a * failed fetch can be mistaken for a report of a clear sky. See * `noObservation` for what that mistake actually did to the fog. */ export interface WeatherFeed extends Feed { /** * ISO-8601 observation time, or `null` when nobody observed anything. * * The *observation* time and not the fetch time, which is the field's whole * value: the server serves from a ten-minute cache, so a body that arrived a * second ago can already describe a sky from ten minutes ago, and the only * way to know how old the weather is is to be told. */ observedAt: string | null; attribution: string[]; } /** * A live weather feed for one place, polled until somebody stops it. * * A watch and not a promise because weather has no natural moment: the map is * open for an hour, the marine layer arrives at some point during it, and a * value fetched once at boot is a photograph of a sky that has since changed. * * `stop()` is not optional housekeeping. Switching city while a poll is in * flight is the ordinary case, not the rare one — the request takes a second * and the button takes a moment — and an answer for the old city landing in the * new city's rig is San Francisco's fog over Long Beach. So a stopped watch * aborts what it has in the air and refuses to publish anything that arrives * anyway. */ export interface WeatherWatch { /** The latest feed. Nobody-was-asked, and not live, until an answer lands. */ current(): WeatherFeed; /** Ask now rather than at the next tick. Ignored while a request is in flight. */ refresh(): void; /** Stop polling, abort anything in flight, and drop any late answer. */ stop(): void; } /** * A running poll of one office's occupancy. * * Same shape as `WeatherWatch` and deliberately so — both are "keep telling me * about this one thing until I leave" — but without the two pieces of judgement * that one carries. Weather has to decide whether an observation is too old to * be honest about and whether it describes the place you are looking at; * occupancy is neither. A roster is true when it is served and meaningless a * moment later, and it is addressed by office id, so it cannot arrive about * somewhere else. */ export interface PresenceWatch { /** 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; } /** * One office's hardware, plus whether anybody actually asked a server about it. * * `live` here means *this deployment answered*, and it is not the same claim as * `synthetic`, which means *nobody observed these readings*. All four * combinations are real deployments and the interface has to be able to say * each of them: * * | live | synthetic | what it is | * | --- | --- | --- | * | false | true | no server, or an anonymous viewer: the local simulator | * | true | true | a server running `TERA_DEVICES_SOURCE=sim` | * | true | false | a real bridge to real hardware | * | false | false | impossible, and nothing constructs it | * * The second row is the reference deployment and the first is every clone of * this repo, which is why both of them have to look alive and both have to say * so. `DeviceDeclaration.disclosure` is the sentence a viewer reads; these two * booleans are what the interface branches on. */ export interface DeviceFeed extends Feed { /** Which source the server said it was using, or `"none"` when nobody answered. */ source: DevicesSourceId; /** True when nobody observed these readings. True for everything this build ships. */ synthetic: boolean; /** Epoch milliseconds of the snapshot, or `null` when there is no snapshot. */ observedAt: number | null; attribution: string[]; } /** * A running poll of one office's hardware. * * `PresenceWatch` with a `current()` on it. Devices need the accessor and * occupancy does not, because a device panel is opened *after* the room has * been drawn — the panel wants the last reading immediately rather than waiting * up to a TTL for the next publish, and re-fetching to answer that would spend * a request on a body this object is already holding. */ export interface DeviceWatch { /** The latest feed. Empty and not live until an answer lands. */ current(): DeviceFeed; /** 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; } /** * A running poll of the state's active fires. * * `DeviceWatch`'s shape rather than `WeatherWatch`'s, because the caller needs * the accessor: a board is drawn before this has answered, and a layer that had * to wait a whole TTL for its first publish would show an empty board for ten * minutes and be indistinguishable from a board with nothing on it. `current()` * is `null` until something lands, which is a third state and is exactly the one * a caption has to be able to say out loud. * * There is no fallback body underneath it and there never will be. An invented * aeroplane is a plausible aeroplane; an invented wildfire is a claim that a * named place is burning, made to somebody who may live there. `null` renders as * a board that says it has not heard. */ export interface FireWatch { /** The latest body, or `null` until one has arrived. */ current(): FiresBody | null; /** Ask now rather than at the next tick. Ignored while a request is in flight. */ refresh(): void; /** Stop polling, abort anything in flight, and drop any late answer. */ stop(): void; } export interface TeraClient { /** What the deployment turned out to be, or `null` if there is no server. */ health(): Promise; markers(): Promise; /** * 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. */ weather(at: Place, options?: { signal?: AbortSignal }): Promise; /** * The sky over one place, kept up to date. `onFeed` fires once per settled * poll, including the ones that change nothing. */ watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch; /** * The traffic source for one region. It fetches on its own schedule and never * blocks the render loop; see `HttpFlights`. * * `fallbackRoutes` is what the simulator flies while the network has not * answered, and defaults to something generated inside the region rather than * to this repo's sample set — the sample set is over San Francisco, and a * default that is only correct for one city is the failure this signature was * changed to prevent. Callers with hand-authored corridors for the city * should pass them; `sampleRoutesFor` in `sample.ts` has them. */ flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource; /** * Every element set this deployment serves, once. `[]` when it serves none, * which is the default and is not an error. * * Not per-region and not watched — see the implementation for both reasons. */ satellites(options?: { signal?: AbortSignal }): Promise; /** * Every active fire this deployment knows about, once. `null` when nothing * answered — which a board must render as "I have not heard", never as "there * is no fire". * * Not per-region, exactly like `satellites`: the whole state's live incident * set is small, the boards are rectangles inside it, and the clip is * `promote()` in `src/server/fires.ts`, which the client has to run anyway to * apply the tier ladder. Asking the server to filter would be asking it to do * a worse version of work that cannot be skipped, and would cost the one * property that makes this cheap: one body, every viewer, one cache key. */ fires(options?: { signal?: AbortSignal }): Promise; /** * The same question, asked on the feed's own cadence, until the caller stops. * * Ten minutes by default, because that is the upstream collector's cron and * anything faster receives identical bytes. `onBody` fires on every settled * poll including the ones that change nothing, so a board can restamp its "as * of" line without waiting for a fire to move. */ watchFires(onBody: (body: FiresBody | null) => void): FireWatch; /** * The statewide reflectivity lattice, once. `null` when nothing answered. * * No watcher, and the absence is the same one `satellites` explains rather * than the one `fires` explains: the composite behind this is a five-minute * product and the server caches it, so the *cadence* belongs to whoever is * looking at it. `main.ts` re-asks off the once-a-minute clock when the body's * own `ttlSeconds` has expired, which is one request per TTL per tab and needs * no ladder here. * * `null` is never an empty sky. `promoteRadar` turns a refusal into "no radar * feed is configured" and an answered-but-dry body into "nothing is falling on * this board", and those are different sentences about the same picture. */ radar(options?: { signal?: AbortSignal }): Promise; /** * Tonight's migration, once, and the quiet reason when there is none. * * `BirdsBody.quiet` is not optional, which is the whole design: 168 of 297 * granules upstream are daylight, so the empty answer is the common one and it * arrives carrying its own explanation rather than as an empty array a caller * has to interpret. */ birds(options?: { signal?: AbortSignal }): Promise; /** * 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 * used to enumerate what exists (CONTRACT.md §6). * * There is deliberately no fallback here. A missing marker can be stood in for * by a fictional one; a missing floorplan cannot be invented, and an app with * a bundled office of its own already has the better answer. */ office(id: string): Promise; /** * Occupancy for one office, or `null` when this deployment will not say. * * Always an authenticated call — `routes/presence.ts` refuses an anonymous * one whatever the deployment's other settings are, because a marker is a * company at an address and a presence is a person at a desk. A build with no * API behind it gets `null` and renders the empty building, which is the * correct picture of an office nobody has told it about. */ presence(officeId: string): Promise; /** * The same question, asked repeatedly, until the caller stops it. * * `null` reaches the callback for every refusal, exactly as the one-shot * does, so a deployment that goes down mid-session is reported rather than * frozen on the last roster it served — the difference between an office that * emptied and an office you have stopped hearing about is one the caller has * to be able to draw. */ watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch; /** * What the hardware in one office is doing. * * Always an authenticated call, exactly like `presence` and for a related * reason: `routes/devices.ts` refuses an anonymous one whatever else the * deployment is set to. A refusal — no API, no session, an office this * viewer may not see — is an empty feed with `live: false`, and the caller's * answer to that is the *locally simulated* studio in `src/devices/sim.ts`, * not an empty panel. See `src/devices/adapter.ts`, which is where that * decision is made once instead of at every call site. */ devices(officeId: string, options?: { signal?: AbortSignal }): Promise; /** * The same question, asked repeatedly, until the caller stops it. * * Modelled on `watchPresence` rather than on `watchWeather`, because it * describes the room somebody is standing in rather than the sky: it stops * dead while the tab is hidden, wakes the moment it comes back, and publishes * only when a reading a viewer could see has changed. */ watchDevices(officeId: string, onFeed: (feed: DeviceFeed) => void): DeviceWatch; /** * Ask one device to do something. Resolves to the state it ended up in, or * `null` for any refusal. * * **A POST, on a route of its own, never folded into the read.** A command * riding in a GET response could be replayed by any shared cache that kept a * copy, and a cache that turned a microphone on by replaying a read is * exactly what the fail-closed `Cache-Control` default in CONTRACT.md §5 * exists to prevent. It is also the first write surface in this product that * changes something another viewer can see, which is the other half of why * it is separate: reading is the demo, writing is the account. */ commandDevice(officeId: string, command: DeviceCommand): Promise; } /** * One GET's worth of options: what to put in the query string, and a way for * the caller to give up on it. * * The signal is the caller's, and is in addition to this module's own timeout * rather than instead of it. They cancel different things: the timeout is about * a server that is slow, and the signal is about an answer that has stopped * being wanted — a city switched, a page unloading — which can happen well * inside a healthy response time. */ interface GetOptions { query?: Record; signal?: AbortSignal; } type Get = (path: string, options?: GetOptions) => Promise; export function createTeraClient(options: TeraApiOptions = {}): TeraClient { const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, ""); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis); /** * One GET, and `null` for every way it can go wrong. * * 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. */ const get: Get = async (path: string, opts: GetOptions = {}): Promise => { if (!doFetch) return null; const controller = new AbortController(); 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}${queryString(opts.query)}`, { signal: controller.signal, headers: { accept: "application/json" }, }); if (!res.ok) return null; // Checked rather than trusted: a static host answers an unknown path with // the SPA shell and a 200, and `res.json()` on HTML throws where a content // type check just returns. const type = res.headers.get("content-type") ?? ""; if (!type.includes("json")) return null; return (await res.json()) as T; } catch { return null; } finally { clearTimeout(timer); opts.signal?.removeEventListener("abort", abort); } }; /** * One POST, and `null` for every way it can go wrong. * * The same deliberate coarseness as `get` — a 401, a 400, a timeout and a * static host answering with its own HTML are one outcome to the caller — with * one difference that matters: **nothing here retries**. A GET that failed can * be repeated because asking twice costs a request; a command that failed may * have been applied before the connection died, and repeating it is the * difference between "turn the microphone on" and "turn the microphone on * twice". Idempotence is not a property this can assume on the caller's * behalf, so a refusal is reported and the panel asks the person. */ const post = async (path: string, body: unknown): Promise => { if (!doFetch) return null; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await doFetch(`${base}${path}`, { method: "POST", signal: controller.signal, headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) return null; const type = res.headers.get("content-type") ?? ""; if (!type.includes("json")) return null; return (await res.json()) as T; } catch { return null; } finally { clearTimeout(timer); } }; return { health: () => get("/health"), async markers(): Promise { const body = await get("/markers"); if (!body || !Array.isArray(body.markers)) return sampleMarkerFeed(); // An API that answers with nothing is the default posture of a server // whose marker source has not been configured, and it is indistinguishable // to a viewer from a broken map. Fall back, and — more importantly — go on // saying "sample", because the alternative is an empty map wearing a // "live data" badge, which is the one outcome worse than no badge at all. if (body.markers.length === 0) return sampleMarkerFeed(); return { // A `WireMarker` *is* a `Marker` with a provenance field on it, so this // is a widening and not a translation — which is the property // `wire.ts` chose the shape for. The provenance itself is the server's // to enforce and is deliberately not re-checked here: a browser // silently dropping rows a self-hoster explicitly allowlisted would look // exactly like an empty database. value: body.markers, live: true, palette: options.palette ?? {}, generatedAt: body.generatedAt ?? null, refused: body.refused ?? [], attribution: body.attribution ?? [], }; }, async weather(at: Place, opts: { signal?: AbortSignal } = {}): Promise { const body = await get("/weather", { query: whereQuery(at), ...(opts.signal ? { signal: opts.signal } : {}), }); return weatherFeed(at, body); }, 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)); }, /** * The satellite catalogue, once. * * The only feed here with no watcher, no back-off ladder and no fallback, * and all three absences are the same fact: element sets are good for days * and the server caches them for hours, so there is nothing to poll for. One * fetch per page load is not a compromise, it is the whole requirement. * * No sample constellation underneath it either, unlike `markers` and * `flights`. An invented aeroplane is a plausible aeroplane; an invented * Starlink is a false claim about a numbered object somebody could go * outside and fail to find. `[]` is the honest answer and it renders as an * empty sky, which is what a box with no satellite source actually has. */ async satellites(opts: { signal?: AbortSignal } = {}): Promise { const body = await get("/satellites", { ...(opts.signal ? { signal: opts.signal } : {}), }); if (!body || !Array.isArray(body.satellites)) return []; return body.satellites; }, /** * The fires, once. * * `null` for every refusal, and the caller must not turn that into an empty * board silently — `promote()` returns a promotion carrying `fetchedAt` and * `ageMs` precisely so the difference can be printed. A board that draws * nothing because nobody answered and a board that draws nothing because * California is not burning are the same picture and different facts. */ fires: (opts: { signal?: AbortSignal } = {}) => get("/fires", { ...(opts.signal ? { signal: opts.signal } : {}) }), watchFires(onBody) { return watchFires(get, onBody); }, /** * The two sky feeds, each once. * * Deliberately not merged into one call. They are two upstream projections * with two TTLs and two failure modes, and a box configured for radar and * not for birds must be able to answer one and refuse the other — which a * combined body could only express by inventing a shape neither route has. */ radar: (opts: { signal?: AbortSignal } = {}) => get("/radar", { ...(opts.signal ? { signal: opts.signal } : {}) }), birds: (opts: { signal?: AbortSignal } = {}) => get("/birds", { ...(opts.signal ? { signal: opts.signal } : {}) }), office: (id) => get(`/offices/${encodeURIComponent(id)}`), /** * Who is in that office. * * `null` for every refusal, which here folds three different facts into one: * no API at all, an API that wants a session this browser does not have, and * an office the server will not name. The caller does the same thing with * all three — draw the building with nobody in it — and a taxonomy it does * not branch on is a taxonomy nobody maintains. `get` already logs nothing * and throws nothing; see the note on coarseness at the top of this file. */ presence: (officeId) => get(`/offices/${encodeURIComponent(officeId)}/presence`), watchPresence(officeId, onBody) { return watchPresence(get, officeId, onBody); }, async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise { const body = await get(devicesPath(officeId), { ...(opts.signal ? { signal: opts.signal } : {}), }); return deviceFeed(body); }, watchDevices(officeId, onFeed) { return watchDevices(get, officeId, onFeed); }, async commandDevice(officeId, command): Promise { const request: DeviceCommandBody = { command }; const body = await post( `${devicesPath(officeId)}/command`, request, ); // Checked rather than trusted, like every other body this file adopts: a // 200 with the wrong shape in it is what a server one version behind this // one sends, and `null` is already the caller's "it did not happen". if (!body || body.device === null || typeof body.device !== "object") return null; return body.device; }, }; } // ---- Weather -------------------------------------------------------------- /** * How often a watch asks, when the last answer was a good one. * * 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 WEATHER_INTERVAL_MS = 10 * 60_000; /** * The ceiling on the back-off ladder a failing watch climbs. * * Backing off is the *normal* path here rather than an outage measure. The * commonest deployment of this bundle is a static host with no API at all, and * on one of those every poll fails forever — so the delay doubles from the ten * minute interval up to an hour and stays there, and a tab left open overnight * makes a dozen requests instead of fifty. The delay never shortens on failure, * which is the retry storm this exists to not be. */ const WEATHER_MAX_INTERVAL_MS = 60 * 60_000; /** * How far a reported observation may be from the place that was asked about * before it is somebody else's weather. * * A hundred and fifty kilometres, and both bounds on that number are real. It * has to be large: a station anywhere on the Bay Area board is a perfectly good * answer for the Bay Area, and the far corner of that board is a hundred and * seventeen kilometres from the point this client asks about, so a tight radius * would throw away correct observations. It has to be small: the two cities in * this build are five hundred and ninety kilometres apart, and the failure being * defended against is a server holding one `TERA_ORIGIN_LAT/LNG` answering every * request with San Francisco's fog while somebody looks at Long Beach. Anything * from about a hundred and twenty to about three hundred separates those two * cases cleanly. * * This is what makes the client safe against a server that ignores the location * it was given — which is every server built before this parameter existed. * `WeatherBody.location` says where the observation is actually from, so the * check is on the answer rather than on a promise about the question. */ const WEATHER_RELEVANCE_KM = 150; /** Kilometres in a nautical mile, for the one place the two units meet. */ const KM_PER_NM = 1.852; /** * How old an observation may be before the map stops calling it the weather. * * An hour, measured from `observedAt` rather than from when the body arrived, * because a body that has just arrived can already be ten minutes old — see * `WeatherFeed.observedAt`. Under that hour a failed poll holds the last good * observation instead of reverting: a deployment that has been showing real * weather all afternoon and drops one request should keep showing it, which is * the same rule `HttpFlights` follows for traffic and for the same reason. * * The hour itself is the marine layer's. Fog over the western half of San * Francisco burns back to the coast in about that on a summer morning, so an * hour-old sky presented as the current one is precisely the lie the `live` * flag was added to prevent — and past that point, handing the sky back to the * local model is the more honest picture. */ const WEATHER_STALE_MS = 60 * 60_000; /** * What every failure resolves to: nobody was asked. * * `null` and emphatically **not** a clear day, which is what this returned * first and what the fallback in an earlier draft of this file was. The two * are different to `atmosphere.ts` in a way that is easy to miss and very * visible on screen. A `WeatherObservation` saying `condition: "clear"` with no * visibility reported is an *observation of a clear sky*, and `apply` treats a * reported clear sky as authoritative: `observed === 0` suppresses the modelled * obscuration outright, on the entirely correct principle that somebody who * looked out of the window beats a climatology. Hand it a clear day the * moment the API 404s and San Francisco loses its marine layer — permanently, * on a zero-config box, which is the commonest way this bundle is run and the * one configuration where the local model is all there is. * * `null` means nobody looked, `apply` runs the marine layer off the season and * the hour, and the fog comes in over the Sunset on a June morning with no * server involved at all. */ function noObservation(): WeatherFeed { return { value: null, live: false, observedAt: null, attribution: [] }; } /** * One weather body, judged. * * Four outcomes and only one of them is an observation. No body at all is * nobody-was-asked. A body about somewhere else is *also* nobody-was-asked, * deliberately: rendering a real observation of a place the viewer is not * looking at is worse than rendering none, because it is wrong and it is * convincing. A `synthetic` body is the server saying in as many words that it * has no source — its numbers were invented by `weather/synthetic.ts` and are * not evidence of anything, so they are dropped for the same reason, and the * local model gets to run instead of being overruled by a fact nobody observed. * What is left is an observation, and it is the only thing that is live. */ function weatherFeed(at: Place, body: WeatherBody | null): WeatherFeed { if (!body) return noObservation(); if (body.synthetic) return noObservation(); if (elsewhere(at, body)) return noObservation(); // `WeatherBody` is structurally a `WeatherObservation` plus fields no // renderer reads, which `atmosphere.ts` says in as many words. The extra // fields ride along harmlessly and the engine never sees them. return { value: body, live: true, observedAt: body.observedAt ?? null, attribution: body.attribution ?? [], }; } /** Whether a body describes a different part of the world from the one asked about. */ function elsewhere(at: Place, body: WeatherBody): boolean { const where = body.location; // A body with no location is one this client cannot place, and an // unplaceable observation is exactly as useful as a wrong one. if (!where || typeof where.lat !== "number" || typeof where.lng !== "number") return true; return distanceNm(at, where) * KM_PER_NM > WEATHER_RELEVANCE_KM; } /** * Poll one place's weather until told to stop. * * Free of any timer the caller has to own. `atmosphere.apply` is pure and the * scene relights from whatever it is handed, so the honest shape is a callback * on new information rather than something the render loop has to remember to * ask. */ function watchWeather(get: Get, at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch { let feed = noObservation(); let receivedAt = 0; /** * When a poll last *settled*, successfully or not — which is a different fact * from when an answer last arrived, and the one the wake-up check needs. * * `receivedAt` is written only on the success path, so on a deployment whose * weather source is configured and failing it stays `0` forever and * `Date.now() - 0` clears every threshold there is. `onVisible` was gated on * it, so every alt-tab back to the map cancelled whichever rung of the * back-off ladder was pending and fired an immediate request: twenty * alt-tabs, twenty requests, which is precisely what * `WEATHER_MAX_INTERVAL_MS` exists not to do. `server/src/upstream.ts` states * the same rule from the other side and calls a clock that only a success * stamps the bug that turns somebody else's outage into your outbound flood. * * `receivedAt` stays, because `tooOld()` is genuinely asking "how old is what * I am showing" and a failed poll does not make it any fresher. */ let attemptedAt = 0; /** The delay the last settled poll asked for, so the wake-up can respect it. */ let nextDelayMs = 0; let failures = 0; let stopped = false; let timer: ReturnType | null = null; let inFlight: AbortController | null = null; /** * Whether what is in hand is still worth showing. * * Prefers the observation time on the body and falls back to when it arrived, * which is the answer for a source that did not stamp one. */ function tooOld(): boolean { const stamped = feed.observedAt === null ? NaN : Date.parse(feed.observedAt); const since = Number.isNaN(stamped) ? receivedAt : stamped; return Date.now() - since > WEATHER_STALE_MS; } function schedule(delayMs: number) { if (stopped) return; if (timer !== null) clearTimeout(timer); nextDelayMs = delayMs; timer = setTimeout(() => void tick(), delayMs); } function publish(next: WeatherFeed) { // A repeated fallback is not news. Every real observation is a fresh object // so it always gets through; two of nothing in a row are both `null`, and // publishing the second only asks the scene to relight itself identically. if (next.value === feed.value && next.live === feed.live) return; feed = next; onFeed(next); } async function tick(): Promise { timer = null; if (stopped) return; // Nothing reaches this with a request already out, but a watch that stalled // would stay stalled until the page reloaded, and that is too quiet a // failure to leave to the reasoning being right. if (inFlight) { schedule(WEATHER_INTERVAL_MS); return; } inFlight = new AbortController(); const body = await get("/weather", { query: whereQuery(at), signal: inFlight.signal, }); inFlight = null; // The watch was stopped while this was in the air. Whatever came back is // the old city's sky and must not be published — `stop()` has already // aborted the request and this is the belt to that pair of braces. It is // also why a cancelled request must not count as a failure below. if (stopped) return; // Every settled poll, either branch. Deliberately not set for the abort // above: "you asked me to stop" is not an attempt that tells us anything // about the server. attemptedAt = Date.now(); if (!body) { failures += 1; // Hold what is in hand until it is too old to be honest about. if (feed.live && tooOld()) publish(noObservation()); schedule(Math.min(WEATHER_INTERVAL_MS * 2 ** (failures - 1), WEATHER_MAX_INTERVAL_MS)); return; } failures = 0; receivedAt = Date.now(); // `weatherFeed` refuses this body too; the branch is here for the schedule. // A box answering about another city will answer that way until somebody // redeploys it, which is not worth a request every ten minutes — whereas a // `synthetic` body, which is also refused, comes from a source that may // come back, and is worth asking about again on the ordinary cadence. if (elsewhere(at, body)) { publish(noObservation()); schedule(ELSEWHERE_SECONDS * 1000); return; } publish(weatherFeed(at, body)); schedule(WEATHER_INTERVAL_MS); } /** * Ask again on the way back into a tab that has been away. * * A laptop shut for six hours wakes showing the sky from before lunch, and * waiting out the rest of a ten-minute interval in front of it is a long time * to look at stale fog. Browsers throttle timers in hidden tabs and may not * have fired ours at all, so the wake-up is the event worth listening for * rather than a shorter interval that would cost a request every time. */ const onVisible = () => { if (document.visibilityState !== "visible") return; // Against the delay that is actually pending, so a source on the back-off // ladder is left where it is. On a healthy watch that delay is // `WEATHER_INTERVAL_MS` and this behaves exactly as it always did. if (Date.now() - attemptedAt >= nextDelayMs) refresh(); }; const hasDocument = typeof document !== "undefined"; if (hasDocument) document.addEventListener("visibilitychange", onVisible); function refresh() { if (stopped || inFlight) return; schedule(0); } schedule(0); return { current: () => feed, refresh, stop() { stopped = true; if (timer !== null) clearTimeout(timer); timer = null; inFlight?.abort(); inFlight = null; if (hasDocument) document.removeEventListener("visibilitychange", onVisible); }, }; } // ---- Markers -------------------------------------------------------------- function sampleMarkerFeed(): MarkerFeed { return { value: SAMPLE_MARKERS, live: false, palette: SAMPLE_PALETTE, generatedAt: null, refused: [], attribution: [], }; } // ---- Asking about a place ------------------------------------------------- /** * The location half of a query, rounded to about a kilometre. * * Rounded for the shared cache, which is the whole reason the precision is * thrown away. `/weather` and `/flights` are served with a public cache header * and the query string is part of the cache key, so two viewers of the same * city have to produce byte-identical URLs or the cache is a per-viewer cache * and the upstream gets hit once per person. A city centre is a constant in * this build and would round identically anyway; a caller that ever passes a * camera position instead gets the same protection for free, along with not * having put anybody's exact position in an access log. */ function whereQuery(at: Place): Record { return { lat: at.lat.toFixed(2), lng: at.lng.toFixed(2) }; } function queryString(query: Record | undefined): string { if (!query) return ""; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) params.set(key, String(value)); const encoded = params.toString(); return encoded === "" ? "" : `?${encoded}`; } // ---- Traffic -------------------------------------------------------------- /** * A `FlightSource` that also knows whether what it is handing over is real. * * The extra method is here rather than on `FlightSource` in `engine/types.ts` * because the engine has no business with provenance: it draws darts at * coordinates, and whether the coordinates were observed is a question about * the deployment. Only the interface layer asks it, so only this layer declares * it. */ export interface TrafficSource extends FlightSource { /** * Narrowed from `FlightSource.poll()`, which may return a promise. * * Not a convenience: it is the file's central promise made into a type. This * is called from the render loop, so it answers from whatever is in hand and * refreshes on the body's own TTL in the background — a `poll()` that awaited * a slow fetch would put a frame's aircraft update behind a round trip. An * adapter that cannot promise that is a `FlightSource` and not one of these. */ poll(): Aircraft[]; /** 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[]; /** * Everything known about one aircraft that is currently being drawn, or * `null` for an id that is not. * * The click target of the whole city board, and it is on this interface for * the same reason `live()` and `attribution()` are: the engine draws darts at * coordinates and the *deployment* knows what those coordinates are. It is * synchronous and answers from what `poll()` last handed over, so a click can * open a card in the same frame — going back to the network for a record this * object already holds would put a panel behind a round trip, and would ask a * volunteer-funded feed for a row it just sent. * * **Available to an anonymous visitor**, and that is a decision rather than * an oversight. An ADS-B position is broadcast unencrypted by the aircraft to * anybody with a receiver; there is nothing here an account could grant * access to, and gating it would cost the first-visit moment this map exists * for while protecting nothing. `access.ts` makes the same argument about the * sky at greater length. */ detail(id: string): AircraftDetail | null; /** Stop fetching and abort anything in flight. Idempotent. */ dispose(): void; } /** * Traffic over HTTP, in whichever of the two shapes the server chose. * * `poll()` is synchronous and never awaits the network, which is the whole * design. `scene.ts` calls it from the render loop, and a source that returned a * promise resolving on a slow fetch would put a frame's aircraft update behind a * round trip; instead the network runs in the background on the body's own TTL * and `poll` answers from whatever is currently in hand. * * The two modes are not symmetrical, and `wire.ts` explains why. A *plan* — the * simulator's routes, a fixed epoch and a seed — is evaluated locally at one * request per TTL, and because the epoch is fixed rather than the server's start * time, two people on different machines see the same aircraft in the same * places. *Live* traffic has no closed form, so it arrives as positions and is * refetched. * * Until the first response lands, and after any failure, this is the simulator * 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 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; * the network is on `nextFetchAt` and is a great deal slower. */ readonly interval = 1; private readonly get: Get; private readonly region: SkyRegion; private readonly fallback: SimulatedFlights; private mode: "fallback" | "plan" | "live" = "fallback"; private plan: FlightsPlanBody | null = null; private planPhase: number[] = []; private aircraft: Aircraft[] = []; private credits: string[] = []; /** * Everything the live body said about each aircraft, keyed by id. * * Only the live path fills this, because only the live path is told anything * an `Aircraft` cannot carry — `WireAircraft.icao24` in particular, which is * the transponder address and is the field a detail card is actually about. * Cleared and rebuilt with every adopted body, so it can never outlive the * positions it describes. */ private readonly wire = new Map(); private latest: Aircraft[] = []; private nextFetchAt = 0; private inFlight: AbortController | null = null; private stopped = false; /** * Fields assigned in the constructor body rather than declared as parameter * properties. * * **This is not a style preference and it must stay this way.** A parameter * property is the one piece of TypeScript syntax that emits code — it is a * hidden assignment, not a type annotation — so Node's type stripping refuses * the *whole module* with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Vite never * cared, so for the entire life of this file `node --test` could not import * `adapters/http.ts` at all: every degrade path, every rung of the back-off * ladder and the whole region filter below had zero coverage, in the module * whose job is to be correct when everything else has failed. * * `engine/flights.ts` says the same thing over `AdsbFlights` and adds the * consequence: the module with the worst bug this project has shipped was, by * construction, the one module that could not be tested. Two modules had that * property; this was the second. */ constructor(get: Get, region: SkyRegion, fallbackRoutes: SimRoute[]) { this.get = get; this.region = region; this.fallback = new SimulatedFlights(fallbackRoutes); } poll(): Aircraft[] { this.refreshIfStale(); const { mode, plan, planPhase } = this; // Kept, so that `detail()` can answer a click about the aircraft that were // actually drawn rather than about a fresh evaluation a few milliseconds // later — the plan path is a function of `Date.now()`, so re-evaluating it // for a lookup would return a position slightly ahead of the dart the // viewer aimed at. this.latest = mode === "plan" && plan ? evaluatePlan(plan, planPhase, Date.now()) : mode === "live" ? this.aircraft : this.fallback.poll(); return this.latest; } /** * One aircraft, as a card. * * Looked up in what was last polled, which is also what is on screen. An id * that has left the feed answers `null` rather than the last known position: * a card showing where something was two minutes ago, with no way to say so, * is the same class of quiet staleness `WeatherFeed.observedAt` exists to * prevent — and the caller's honest response is to close the card. */ detail(id: string): AircraftDetail | null { const found = this.latest.find((a) => a.id === id); if (found === undefined) return null; const wire = this.wire.get(id); return aircraftDetail(found, { // Only a live body carries an address, and only a live body was observed. // The plan's aircraft are this repo's own arithmetic and say so. ...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }), // The tail number and the type designator, off the same ODbL row as the // position. `Aircraft` has nowhere to put either — they move no pixels — // so they travel here, beside the address, for exactly the same reason. // This is the enrichment the owner wanted a commercial feed for; the // community feeds carried it all along and the server was dropping it. ...(wire?.registration === undefined ? {} : { registration: wire.registration }), ...(wire?.type === undefined ? {} : { type: wire.type }), observed: this.mode === "live", attribution: this.attribution(), from: this.region.center, }); } /** * 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.stopped || this.inFlight || now < this.nextFetchAt) return; const controller = new AbortController(); this.inFlight = controller; void this.get("/flights", { query: { ...whereQuery(this.region.center), radiusNm: Math.round(this.region.radiusNm) }, signal: controller.signal, }) .then((body) => { // Disposed while the request was in the air: the city has changed, this // object is nobody's traffic source any more, and a late answer must // not restart its clock or write to its state. if (this.stopped) return; if (!body) { // Hold whatever was already in hand rather than reverting to the // simulator: a deployment that has been showing real traffic for an // hour and drops one request should keep showing it, slightly stale, // not silently swap in fiction. this.nextFetchAt = now + RETRY_SECONDS * 1000; return; } 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(() => { 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 = []; // Both of these describe the body that is about to be adopted, so both are // dropped before it is looked at rather than on each of the four ways this // method can decide not to adopt it. A transponder address left over from a // previous body would otherwise be attached, by id collision, to whatever // the next one draws. this.wire.clear(); 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; } /** * How stale these coordinates already are, in seconds, at the moment they * are adopted. * * Two terms, and both of them are real. `WireAircraft.ageSeconds` is how * old the fix was when the *upstream* answered — a fraction of a second on * a healthy feed. `Date.now() - observedAt` is everything since: this * box's cache TTL, which is five to fifteen seconds by design, plus the * request that carried it. A dead-reckoner told only the first term draws * the whole sky a cache-TTL behind, uniformly, which is the sort of error * that never gets noticed because everything is wrong together. * * Computed once here rather than per poll because `poll()` hands back these * same objects for the body's whole life and the flight layer skips a * repeated position without looking at it — the age matters at the instant * the layer first sees the position, and that is this instant. * * Clamped, and the clamp is not decoration: `observedAt` comes off the * wire, so a server with a wrong clock can make this negative (a fix from * the future) or enormous (a fix from last week), and either would be * integrated into a position. Anything outside the window is treated as * "no useful answer" and the position is taken as current. */ const observedAt = Number.isFinite(body.observedAt) ? body.observedAt : Date.now(); const latency = clampSeconds((Date.now() - observedAt) / 1000); /** * The wire records, as the engine's `Aircraft`. * * A copy rather than a pass-through, which the live path did not need until * the wire started carrying velocity: `WireAircraft` is structurally an * `Aircraft` and always was, but `ageSeconds` is the one field whose value * is different on the two sides of this line. On the wire it means "how old * when the server saw it"; to the engine it means "how old when you were * handed it", and the difference is the round trip. */ this.aircraft = here.map((a) => ({ ...a, ageSeconds: clampSeconds((a.ageSeconds ?? 0) + latency), })); // Only the live path has anything to record: a plan carries routes, not // transponders. Cleared at the top of this method, so a record that has // left the feed leaves this map with it rather than surviving to answer a // click about an aircraft nobody is drawing. for (const a of here) this.wire.set(a.id, a); 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; } } /** * A staleness in seconds, or zero for anything that is not a usable one. * * The ceiling is `MAX_STALE_SECONDS` and the floor is zero. A fix cannot be * from the future, however confidently a clock says so, and one older than the * ceiling is not something to advance a position from — `engine/flights.ts` * stops reckoning at a minute for the same reason. */ function clampSeconds(value: number): number { if (!Number.isFinite(value) || value <= 0) return 0; return Math.min(value, MAX_STALE_SECONDS); } /** * The oldest a fix may be said to be. A minute, matching the point at which the * flight layer stops dead-reckoning and the point at which both flight sources * give up holding their last snapshot. */ const MAX_STALE_SECONDS = 60; /** * 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. * * Same generator and same order as `SimulatedFlights`, which is what makes the * server's promise true: every viewer draws the seed once, in route order, and * arrives at the same sky. */ function phasesFor(plan: FlightsPlanBody): number[] { const rand = seededRandom(plan.seed); return plan.routes.map(() => rand()); } function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Aircraft[] { const seconds = (nowMs - plan.t0) / 1000; // `WireSimRoute` is structurally `SimRoute`; the restatement in `wire.ts` is // 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(" + ")}`; } // ---- Presence -------------------------------------------------------------- /** * How often to ask who is in, while somebody is standing in the room. * * Thirty seconds, and the number comes from what the data does rather than from * what the network can stand. Weather is polled every ten minutes because * nothing upstream of it moves faster; occupancy moves when a person stands up, * which is a scale of seconds, and a floor that takes ten minutes to notice * somebody sat down is a floor plan of the recent past. Thirty is close enough * to feel like the room and far enough that a member with the tab open all day * makes about a thousand requests for a few kilobytes each. */ const PRESENCE_INTERVAL_MS = 30_000; /** * The ceiling on backoff. Five minutes, not the weather watch's hour. * * A box that is down comes back, and the difference between the two watches is * what the user is doing while it is down: nobody is staring at the sky waiting * for it to be redescribed, and somebody *is* standing in a room they expect to * see people arrive in. An hour of silence there reads as a broken feature * rather than as a quiet API. */ const PRESENCE_MAX_INTERVAL_MS = 5 * 60_000; /** * Poll one office's occupancy until told to stop. * * Two things this does that `watchWeather` does not, both for the same reason — * this one runs while a person is looking at the thing it describes: * * - **It stops dead while the tab is hidden**, and asks again the moment it * comes back. A backgrounded tab polling a roster nobody can see is waste on * both ends, and it is the commonest state a long-lived office tab is in. * Coming back has to be immediate rather than at the next tick, or returning * to the tab shows a floor up to thirty seconds stale at exactly the moment * somebody is looking hardest. * - **It publishes only on change**, by comparing a signature rather than the * object. Every answer is a fresh array, so identity says nothing; without * the comparison a still floor would rebuild its presence meshes twice a * minute forever, which is a visible cost for no information. */ function watchPresence( get: Get, officeId: string, onBody: (body: PresenceBody | null) => void, ): PresenceWatch { let failures = 0; let stopped = false; let timer: ReturnType | null = null; let inFlight: AbortController | null = null; let signature: string | null = null; let published = false; const path = `/offices/${encodeURIComponent(officeId)}/presence`; function schedule(delayMs: number) { if (stopped) return; if (timer !== null) clearTimeout(timer); timer = setTimeout(() => void tick(), delayMs); } /** * What a body amounts to, for the purpose of "is this news". * * Seat, colour and label, in the order served. Not `observedAt`, which moves * on every poll of an unchanged floor and would defeat the whole comparison, * and not a `JSON.stringify` of the body for the same reason. */ function signatureOf(body: PresenceBody | null): string { if (body === null) return "none"; return body.people.map((p) => `${p.seatId}${p.colorKey}${p.label}`).join(""); } function publish(body: PresenceBody | null) { const next = signatureOf(body); // The first answer always goes through, even when it matches the empty // signature a caller might have assumed. "I asked and there is nobody" and // "I have not asked yet" are different states, and only one of them should // leave a building unpopulated on purpose. if (published && next === signature) return; signature = next; published = true; onBody(body); } async function tick(): Promise { timer = null; if (stopped) return; // Hidden tabs do not ask. The visibility listener below is what wakes this // up again, so there is no timer left running to catch. if (typeof document !== "undefined" && document.visibilityState === "hidden") 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(PRESENCE_INTERVAL_MS); return; } inFlight = new AbortController(); const body = await get(path, { signal: inFlight.signal }); inFlight = null; // The watch was stopped while this was in the air — the office was left or // disposed. Whatever came back describes a room nobody is in any more, and // publishing it would write people into a torn-down scene. It is also why a // cancelled request must not count as a failure below. if (stopped) return; publish(body); if (body === null) { failures += 1; schedule(Math.min(PRESENCE_INTERVAL_MS * 2 ** (failures - 1), PRESENCE_MAX_INTERVAL_MS)); return; } failures = 0; schedule(PRESENCE_INTERVAL_MS); } function onVisibility() { if (stopped) return; if (document.visibilityState === "visible") { // Immediately, not at the next tick. Somebody has just come back to this // tab, and the first thing they look at is the room. failures = 0; schedule(0); } else if (timer !== null) { clearTimeout(timer); timer = null; } } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisibility); } void tick(); return { refresh() { if (stopped || inFlight) return; schedule(0); }, stop() { stopped = true; if (timer !== null) clearTimeout(timer); timer = null; inFlight?.abort(); inFlight = null; if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility); } }, }; } // ---- Devices -------------------------------------------------------------- /** * How often to ask what the hardware is doing, when the server does not say. * * Five seconds, and the number comes from the data rather than from the * network. Occupancy moves when somebody stands up and is polled every thirty; * a level meter moves continuously and a mute button moves the instant it is * pressed, and a panel that took half a minute to notice somebody else had * muted the room would read as broken. The server's `ttlSeconds` overrides this * whenever it answers — `TERA_DEVICES_TTL` is the operator's dial and this is * only what to do before they have had their say. * * It is still a poll rather than a stream. The realtime service exists and this * deliberately does not use it: a device panel is open for a minute at a time * on a handful of tabs, and a socket per viewer for a body this size is a * standing cost for an occasional need. If the panels are ever open all day, * that is the moment to revisit it. */ const DEVICES_INTERVAL_MS = 5_000; /** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */ const DEVICES_MIN_INTERVAL_MS = 2_000; const DEVICES_MAX_INTERVAL_MS = 60_000; /** * The ceiling on the back-off ladder, five minutes — `watchPresence`'s number, * not the weather watch's hour, and for the same reason it gives: somebody is * standing in the room this describes. */ const DEVICES_MAX_BACKOFF_MS = 5 * 60_000; /** Nobody answered. Empty, not live, and not claiming to have observed anything. */ function noDevices(): DeviceFeed { return { value: [], live: false, source: "none", synthetic: true, observedAt: null, attribution: [] }; } /** * One devices body, judged. * * Two outcomes, and the check is on the array rather than on its length. A body * with `devices: []` is a real answer — an office nobody has declared any * hardware in, which is most offices — and it is `live`, because the deployment * answered and said so. A body with no array in it at all is not an answer, and * it is the shape a server one version behind this one sends. The same * distinction `adsb.ts` draws between an empty circle of sky and an unreadable * envelope, for the same reason: coercing the second into the first serves * fiction under a live badge. */ function deviceFeed(body: DevicesBody | null): DeviceFeed { if (!body || !Array.isArray(body.devices)) return noDevices(); return { value: body.devices, live: true, source: body.source ?? "none", // Absent means synthetic. A body that did not say whether anybody observed // its readings is not a body that may be presented as observation. synthetic: body.synthetic !== false, observedAt: typeof body.observedAt === "number" ? body.observedAt : null, attribution: Array.isArray(body.attribution) ? body.attribution : [], }; } /** * Poll one office's hardware until told to stop. * * `watchPresence` with a TTL from the body and a signature that covers readings * instead of seats. The three properties it inherits are the ones that matter * and they are argued for at length there: * * - **stops dead while the tab is hidden**, and asks immediately on the way * back, because a backgrounded panel polling a meter nobody can see is * waste at both ends; * - **publishes only on change**, by comparing `deviceStateSignature` — every * answer is a fresh array, so identity says nothing, and a still studio * would otherwise rebuild its panel every few seconds forever; * - **reports a refusal** rather than freezing on the last good reading, so * "the room went quiet" and "I have stopped hearing about the room" stay * distinguishable. * * The signature deliberately ignores `observedAt`, which moves on every poll of * an unchanged studio and would defeat the whole comparison. */ function watchDevices( get: Get, officeId: string, onFeed: (feed: DeviceFeed) => void, ): DeviceWatch { let feed = noDevices(); let signature: string | null = null; let published = false; let failures = 0; let stopped = false; let timer: ReturnType | null = null; let inFlight: AbortController | null = null; const path = devicesPath(officeId); function schedule(delayMs: number) { if (stopped) return; if (timer !== null) clearTimeout(timer); timer = setTimeout(() => void tick(), delayMs); } function publish(next: DeviceFeed) { const nextSignature = `${next.live ? "1" : "0"}${deviceStateSignature(next.value)}`; // The first answer always goes through, even when it matches the empty // signature the caller may have assumed. "I asked and there is nothing" and // "I have not asked yet" are different states and only one of them should // leave a panel saying so on purpose. feed = next; if (published && nextSignature === signature) return; signature = nextSignature; published = true; onFeed(next); } async function tick(): Promise { timer = null; if (stopped) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") 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(DEVICES_INTERVAL_MS); return; } inFlight = new AbortController(); const body = await get(path, { signal: inFlight.signal }); inFlight = null; // Stopped while this was in the air: the office was left or the panel was // closed. Whatever came back describes a room nobody is looking at, and it // is also why a cancelled request must not count as a failure below. if (stopped) return; publish(deviceFeed(body)); if (body === null) { failures += 1; schedule(Math.min(DEVICES_INTERVAL_MS * 2 ** (failures - 1), DEVICES_MAX_BACKOFF_MS)); return; } failures = 0; schedule(intervalFor(body)); } function onVisibility() { if (stopped) return; if (document.visibilityState === "visible") { // Immediately rather than at the next tick: somebody has just come back // to this tab and the panel in front of them is the stale thing. failures = 0; schedule(0); } else if (timer !== null) { clearTimeout(timer); timer = null; } } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisibility); } void tick(); return { current: () => feed, refresh() { if (stopped || inFlight) return; schedule(0); }, stop() { stopped = true; if (timer !== null) clearTimeout(timer); timer = null; inFlight?.abort(); inFlight = null; if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility); } }, }; } /** * The cadence the server asked for, clamped. * * Checked rather than trusted, exactly as `HttpFlights.adopt` learned to be: an * absent `ttlSeconds` makes `Math.max(1, undefined)` a `NaN`, `NaN` clears every * comparison, and the poll interval quietly becomes the frame rate. The floor * is the load-bearing half — a `TERA_DEVICES_TTL=0` that reads like "as fresh * as possible" would otherwise be one request per tick per open tab. */ function intervalFor(body: DevicesBody): number { const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : DEVICES_INTERVAL_MS; return Math.min(DEVICES_MAX_INTERVAL_MS, Math.max(DEVICES_MIN_INTERVAL_MS, asked)); } /** One place the route is spelled, so the read and the command cannot drift apart. */ function devicesPath(officeId: string): string { return `/offices/${encodeURIComponent(officeId)}/devices`; } // ---- Fires ---------------------------------------------------------------- /** * How often to ask, when the server does not say. * * Ten minutes, which is the collector's own cron upstream and the server's * `TERA_FIRES_TTL` default. Asking faster spends two machines' work to be handed * the same bytes: the projection endpoint holds a sixty-second cache, the API * holds ten minutes, and the data itself moves when an agency updates an * incident — which for a large fire is a handful of times a day. * * The other end of the argument is what the board does with it. A fire glyph is * a position and an acreage; neither moves in a way anyone can see over ten * minutes, and the plume drifts on wind that arrives from a different feed * entirely. This is a map, not a dispatch console. */ const FIRES_INTERVAL_MS = 10 * 60_000; /** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */ const FIRES_MIN_INTERVAL_MS = 60_000; const FIRES_MAX_INTERVAL_MS = 60 * 60_000; /** * The ceiling on the back-off ladder. * * An hour, `watchWeather`'s number rather than `watchDevices`'s five minutes, * and for the reason `watchWeather` gives: the commonest deployment of this * bundle is a static host with no API at all, where every poll fails forever. * Nobody is standing inside a wildfire the way somebody is standing in the room * a device watch describes. */ const FIRES_MAX_BACKOFF_MS = 60 * 60_000; /** * Poll the fire feed until told to stop. * * `watchDevices` without the change comparison. The publish-on-change trick is * deliberately absent: an unchanged body still carries a **newer `fetchedAt`**, * and that is the field a quiet board is captioned with. Suppressing a republish * because no fire moved would freeze the age on screen at the moment of the last * change, so a feed that died an hour ago would go on displaying "4 minutes * ago" — which is the precise failure a stated fetch age exists to prevent. * * It keeps the other two properties: it stops dead while the tab is hidden and * asks immediately on the way back, and it reports a refusal as `null` rather * than freezing on the last good body. */ function watchFires(get: Get, onBody: (body: FiresBody | null) => void): FireWatch { let body: FiresBody | null = null; let failures = 0; let stopped = false; let timer: ReturnType | null = null; let inFlight: AbortController | null = null; function schedule(delayMs: number) { if (stopped) return; if (timer !== null) clearTimeout(timer); timer = setTimeout(() => void tick(), delayMs); } async function tick(): Promise { timer = null; if (stopped) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (inFlight) { schedule(FIRES_INTERVAL_MS); return; } inFlight = new AbortController(); const next = await get("/fires", { signal: inFlight.signal }); inFlight = null; // Stopped while this was in the air — the board was left. Whatever came back // describes a map nobody is looking at, and a cancelled request must not // count as a failure. if (stopped) return; // Checked rather than trusted, like every other body this file adopts: a 200 // with the wrong shape in it is what a server one version behind this one // sends, and `promote()` downstream would read it as an empty board. const usable = next !== null && Array.isArray(next.incidents) ? next : null; body = usable; onBody(usable); if (usable === null) { failures += 1; schedule(Math.min(FIRES_INTERVAL_MS * 2 ** (failures - 1), FIRES_MAX_BACKOFF_MS)); return; } failures = 0; schedule(firesIntervalFor(usable)); } function onVisibility() { if (stopped) return; if (document.visibilityState === "visible") { failures = 0; schedule(0); } else if (timer !== null) { clearTimeout(timer); timer = null; } } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisibility); } void tick(); return { current: () => body, refresh() { if (stopped || inFlight) return; schedule(0); }, stop() { stopped = true; if (timer !== null) clearTimeout(timer); timer = null; inFlight?.abort(); inFlight = null; if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility); } }, }; } /** * The cadence the server asked for, clamped. * * The floor is the load-bearing half, exactly as it is for devices: a * `TERA_FIRES_TTL` of zero reaching a browser unchecked is one request per tick * per open tab, against a box that is itself calling another machine. */ function firesIntervalFor(body: FiresBody): number { const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : FIRES_INTERVAL_MS; return Math.min(FIRES_MAX_INTERVAL_MS, Math.max(FIRES_MIN_INTERVAL_MS, asked)); }