# Adapters

Everything in `src/engine/` renders data and takes no position on what it means.
A `Marker` is a point with a `colorKey`; the engine looks that key up in a
palette the caller supplies and will never learn that `rejected` is red. A
`FlightSource` is an interface with a `poll()` on it. A `WeatherObservation` is
six numbers.

This directory is where those become somebody's actual data. It is the only part
of the browser build that knows an API exists, and keeping it here is what lets
one renderer serve a private career map, a public sector map and whatever anyone
else builds without any of them being a fork. See ARCHITECTURE.md §3.3.

| file | what it is |
| --- | --- |
| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it |
| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` |

Devices have a seam of their own next door, in `src/devices/adapter.ts`, for a
reason worth stating: it is the one feed with a **write** on it, and the write
does not degrade the way a read does. See "Devices" below.

## The fallback is the product, not the safety net

`http.ts` never throws and never leaves the map empty. No server, a 404, a
timeout, a static host answering `/api/v1/markers` with its own `index.html` —
all of it lands on the sample data, and the city keeps rendering.

That is deliberate and 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). `npm run build` deployed to any static host is a
working Tera. Pointing it at a server is an upgrade.

Every response carries `live: boolean` so the difference is visible to the app
above. An interface that shows invented companies exactly the way it shows real
ones is the one failure mode this arrangement can have, and the flag is there so
it does not have to happen.

```ts
const tera = createTeraClient();               // same-origin /api/v1
const markers = await tera.markers();
const scene = await createScene(stage, {
  city: SAN_FRANCISCO,
  markerPalette: markers.palette,
  flights: tera.flights(regionOf(SAN_FRANCISCO), sampleRoutesFor("sf")),
});
scene.setMarkers(markers.value);
if (!markers.live) showSampleDataNotice();
```

Three things about that call are load-bearing.

`createScene` is **async** and takes a `Stage` rather than a canvas: the
heightfield is built in a Worker, and the renderer outlives any one city, so the
stage is created once for the page and handed to each scene in turn.

`flights` takes a **region**, not a client-wide origin. The Bay Area and SoCal
are six hundred kilometres apart and a single configured origin served one of
them and lied to the other. `regionOf(city)` derives it from the city's own
bounds, so a city pack added later needs no configuration to get its own sky.
The second argument is the simulated traffic to fly when the API has none, which
is what keeps the zero-config case from showing an empty sky.

And note the ordering: `markerPalette` is fixed when the scene is built, so the
markers have to be awaited first. `markers.palette` is the sample palette when
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
is real — the sample keys are not your keys.

## Clicking an aircraft

`TrafficSource.detail(id)` answers from what `poll()` last handed over —
callsign, ICAO 24-bit address, altitude in both units, heading as degrees and as
a compass point, position, distance from the board centre, whether anybody
observed it, and the credit lines owed for it. Synchronous, so a click opens a
card in the same frame rather than behind a round trip, and `null` for an
aircraft that has left the feed rather than a card showing where something was
two minutes ago.

**It is 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 forty-dollar 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.

Two fields are about the data rather than about the aeroplane. `observed`
travels with the aircraft because a card is read on its own, away from any
corner label, and a fabricated flight number in the same frame as a real one is
the confusion `live` exists to prevent. `attribution` travels with it because a
card is where the data is *displayed*, which is what an ODbL notice is about.
The address is `null` unless the feed gave a real one: the simulator's ids are
route names and the `~`-prefixed ids both community feeds emit for TIS-B and
MLAT targets are not ICAO addresses, and somebody pastes that field into a
registry lookup.

## Devices: reading is the demo, writing is the account

`src/devices/adapter.ts` is the seam, and it chooses between two strategies once,
at construction:

- **the API**, when the deployment has a device source (`/health` says so) and
  the viewer may read it; or
- **the simulator in this tab** — `src/devices/sim.ts`, seeded, deterministic,
  `synthetic: true` and `live: false` — for everybody else.

The second is not a degraded mode, it is the anonymous visitor's studio and the
zero-config clone's studio, and it is the same argument the marker fallback
makes one section up: a studio is never dark. `GET /offices/:id/devices` refuses
an anonymous caller, and it should — a reading describes a room somebody is
standing in — so the refusal produces a working, honestly-labelled instrument
rather than a dead one.

**Commands do not get the same treatment**, and this is the one place in this
directory where a failure is reported rather than papered over. On the API
strategy a refused command is `null` and the interface says so; it is never
quietly applied to a local copy, because a control that appears to work and
changes nothing anybody else can see is worse than one that says no. On the
simulated strategy a command is applied locally and openly, because nothing
there claims to be a real room.

A command travels as a **POST on a route of its own** and never in a read body.
A shared cache that kept a GET which turned a microphone on could replay it, and
that is precisely what the fail-closed `Cache-Control` default in CONTRACT.md §5
exists to prevent.

`src/devices/adapter.ts` owns no timer: the simulated strategy is advanced by
`tick(dt)` from whatever render loop already exists, and the API strategy's
polling lives in `watchDevices` here in `http.ts`, where every other watch's
timer already is.

## No real company data ships in this repo

Two separate constraints want the same thing here, which is the reason this
arrangement is worth the indirection rather than just committing a JSON file.

**Privacy.** Pipeline status — who is talking to whom, and who said no — is
private. A public repo is the wrong place for it.

**Licence, which is the sharper one.** Real positions are geocoded, and a
geocoder built on OpenStreetMap returns ODbL data. ODbL is share-alike, and
serving a snapshot of those coordinates from a public endpoint is *Publicly
Using a Derivative Database* — §4.3 attribution and §4.4 share-alike attach to
the served data whether or not the rows live in the repo. Keeping the table
off-disk hides that obligation; it does not discharge it. See ARCHITECTURE.md
§3.2 and the correction in CONTRACT.md §8.

So the rule is about the geocoder, not about storage:

- Geography in `src/cities/` is **traced by hand**. Original expression, ours,
  Apache-2.0. Never OSM, never Nominatim.
- Real markers arrive over the API at runtime, each carrying a
  `CoordinateProvenance`, and the server refuses any row whose provenance is not
  on a non-ODbL allowlist — `us-census`, `hand-placed`, `synthetic`.
- `http.ts` passes `refused` straight through rather than swallowing it, because
  a gate that drops rows silently is indistinguishable from an empty database.
  It does **not** re-run the gate in the browser: the allowlist is the server's,
  and a self-hoster who added their own provenance value should not watch their
  own rows vanish client-side.
- Everything in `sample.ts` is invented. The companies do not exist, the
  positions were typed by hand from a general sense of where San Francisco's
  neighbourhoods are, and the names are absurd on purpose so that none of them
  can be mistaken for a real business.

## Writing your own

`http.ts` is one adapter, not the adapter. Anything that can produce
`Marker[]`, a `WeatherObservation` and a `FlightSource` is one — a local JSON
file, a Postgres query through your own backend, an SDR on the windowsill. The
engine imports nothing from this directory, so an adapter can be deleted, forked
or replaced without touching a line of the renderer.

Two things to keep if you write one:

**Traffic must not block the render loop.** `poll()` may be synchronous, and
`HttpFlights` is: it answers from whatever is in hand and refreshes on the
body's own TTL in the background. A `poll()` that awaits a slow fetch puts a
frame behind a round trip.

**FlightRadar24 is not an option.** Their terms do not permit scraping and do
not permit redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
publishing instructions for breaking a ToS and shipping data it has no right to
relicense. `src/engine/flights.ts` ships a simulator and points at the open
community ADS-B feeds instead; anything commercial belongs in an adapter in a
private deployment. See ARCHITECTURE.md §4.
