acf4d1a510
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.
**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.
The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.
**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.
`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.
**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.
At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.
**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.
"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.
**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."
Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.
Tests 1,340 -> 1,540, server 280 -> 295.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2001 lines
85 KiB
TypeScript
2001 lines
85 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
value: T;
|
|
live: boolean;
|
|
}
|
|
|
|
export interface MarkerFeed extends Feed<Marker[]> {
|
|
/** 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<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;
|
|
}
|
|
|
|
/**
|
|
* 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<DeviceState[]> {
|
|
/** 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<HealthBody | null>;
|
|
markers(): Promise<MarkerFeed>;
|
|
/**
|
|
* 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<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;
|
|
/**
|
|
* 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<SatelliteElements[]>;
|
|
/**
|
|
* 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<FiresBody | null>;
|
|
/**
|
|
* 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<RadarBody | null>;
|
|
/**
|
|
* 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<BirdsBody | null>;
|
|
/**
|
|
* 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<OfficeDoc | null>;
|
|
/**
|
|
* 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<PresenceBody | null>;
|
|
/**
|
|
* 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<DeviceFeed>;
|
|
/**
|
|
* 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<DeviceState | 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;
|
|
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 <T,>(path: string, opts: GetOptions = {}): Promise<T | null> => {
|
|
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 <T,>(path: string, body: unknown): Promise<T | null> => {
|
|
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<HealthBody>("/health"),
|
|
|
|
async markers(): Promise<MarkerFeed> {
|
|
const body = await get<MarkersBody>("/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<WeatherFeed> {
|
|
const body = await get<WeatherBody>("/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<SatelliteElements[]> {
|
|
const body = await get<SatellitesBody>("/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<FiresBody>("/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<RadarBody>("/radar", { ...(opts.signal ? { signal: opts.signal } : {}) }),
|
|
|
|
birds: (opts: { signal?: AbortSignal } = {}) =>
|
|
get<BirdsBody>("/birds", { ...(opts.signal ? { signal: opts.signal } : {}) }),
|
|
|
|
office: (id) => get<OfficeDoc>(`/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<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`),
|
|
|
|
watchPresence(officeId, onBody) {
|
|
return watchPresence(get, officeId, onBody);
|
|
},
|
|
|
|
async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise<DeviceFeed> {
|
|
const body = await get<DevicesBody>(devicesPath(officeId), {
|
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
});
|
|
return deviceFeed(body);
|
|
},
|
|
|
|
watchDevices(officeId, onFeed) {
|
|
return watchDevices(get, officeId, onFeed);
|
|
},
|
|
|
|
async commandDevice(officeId, command): Promise<DeviceState | null> {
|
|
const request: DeviceCommandBody = { command };
|
|
const body = await post<DeviceCommandResultBody>(
|
|
`${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<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 {
|
|
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<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 {
|
|
/**
|
|
* 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<string, WireAircraft>();
|
|
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<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
|
|
// 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<typeof setTimeout> | 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 " |