1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+26
View File
@@ -235,6 +235,32 @@ Two changes make it work, and both are cheap now:
SF gets one focus region covering the whole city and behaves exactly as it does SF gets one focus region covering the whole city and behaves exactly as it does
now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs. now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs.
### 5.1 The heightfield is built off the main thread
Both numbers above are seconds during which nothing rendered and nothing
responded, because the build ran on the thread that paints. It runs in a Worker
now (`src/engine/terrain.worker.ts`), which is why `createScene` is async and
takes a `Stage` rather than a canvas.
Three consequences worth knowing before touching it:
- **The city pack has to stay structured-cloneable.** It is posted to the
worker as data. A `City` that acquires a method, a class instance or a
closure stops being sendable, and the fix is to remove it rather than to
JSON round-trip around it — a city pack containing code is the thing §2
exists to prevent.
- **The sampled accessors stay synchronous.** `elevationAt`, `groundAt` and
`isLand` are called in tight loops by `terrain.ts`, `blocks.ts` and
`minimap.ts`; the asynchrony is confined to *becoming ready*, and sampling
before then is a documented error rather than a silent zero.
- **There is a main-thread fallback and it is not optional.** An environment
without Workers — a `file://` open, a locked-down browser — still has to
build and render, because "clone it and it works" has no exception clause.
The same reasoning put Spaces behind an `await import()`: the office is a large
slice of the bundle and most visitors never open it, so it is fetched when
somebody reaches for the door rather than by everybody at boot.
--- ---
## 6. How Workie feeds it ## 6. How Workie feeds it
+31
View File
@@ -96,3 +96,34 @@ The flight sources shipped in this repository are a simulator (original work)
and clients for open community ADS-B feeds. No commercial aviation data and clients for open community ADS-B feeds. No commercial aviation data
provider's data is included or redistributed, and no client for a provider provider's data is included or redistributed, and no client for a provider
whose terms prohibit such use is present. whose terms prohibit such use is present.
No aircraft position is committed to this repository; the community feeds are
fetched at runtime by a deployment that has been configured to use one. Where
adsb.lol answers, the API attaches the credit line that feed asks for and the
browser displays it in the shortcuts card alongside the weather credits below.
WEATHER DATA
------------
No weather observation is committed to this repository either. The default
build observes nothing at all and models the sky locally, and the shipped
sources are all opt-in.
Two of them carry an attribution obligation, and it is met at runtime rather
than here, because which one applies is a property of the deployment and not of
the source:
- MET Norway (met.no) — CC BY 4.0
- Open-Meteo.com — CC BY 4.0
The API emits the credit line each of them asks for in the `attribution` array
on the weather body, and the browser displays whatever it is sent, for as long
as that source is what is on screen. See `server/src/weather/` for the strings
and `renderCredits` in `src/main.ts` for where they land. A deployment that
turns one of these on and strips the credit is the party in breach, not this
repository — but the plumbing to comply ships working, on purpose.
The US National Weather Service (api.weather.gov) is a United States government
work in the public domain and carries no such obligation, which is why it sends
no attribution array; claiming one would be inventing a licence term.
+21 -3
View File
@@ -35,7 +35,7 @@ Three tiers, resolved once at boot by `src/access.ts`:
| the map, the plan view, the named chapters | ✅ | ✅ | ✅ | | the map, the plan view, the named chapters | ✅ | ✅ | ✅ |
| the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth | | the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth |
| live markers and live traffic | — | ✅ | ✅ | | live markers and live traffic | — | ✅ | ✅ |
| the time scrubber and debug readouts | — | — | ✅ | | the godmode panel (`G`) — clock, weather override, counters | — | — | ✅ |
**These are drawing decisions, not a security boundary**, and `src/access.ts` **These are drawing decisions, not a security boundary**, and `src/access.ts`
says so at length. Live data and office presence are withheld by the *API*, from says so at length. Live data and office presence are withheld by the *API*, from
@@ -56,18 +56,28 @@ npm run dev
```ts ```ts
import { createScene } from "@lumbridge/tera/engine/scene.ts"; import { createScene } from "@lumbridge/tera/engine/scene.ts";
import { createStage } from "@lumbridge/tera/engine/stage.ts";
import SAN_FRANCISCO from "@lumbridge/tera/cities/sf.ts"; import SAN_FRANCISCO from "@lumbridge/tera/cities/sf.ts";
const scene = createScene(canvas, { // One stage per canvas, for the life of the page. Cities are put on it and
// taken off again; a renderer per city leaks its shadow map on every switch.
const stage = createStage(canvas);
const scene = await createScene(stage, {
city: SAN_FRANCISCO, city: SAN_FRANCISCO,
markerPalette: { hiring: 0x4ade80, closed: 0xef4444 }, markerPalette: { hiring: 0x4ade80, closed: 0xef4444 },
}); });
scene.setMarkers([ scene?.setMarkers([
{ id: "1", lat: 37.7765, lng: -122.4241, label: "Somewhere", colorKey: "hiring" }, { id: "1", lat: 37.7765, lng: -122.4241, label: "Somewhere", colorKey: "hiring" },
]); ]);
``` ```
`createScene` is async because the heightfield is built in a Worker — half a
million samples, about 730 ms on the Bay Area, and not on the main thread. It
resolves to `null` if the build was abandoned through `options.signal`, which is
what makes switching city mid-build cheap.
The engine renders `Marker[]` and looks colours up by `colorKey` in a palette The engine renders `Marker[]` and looks colours up by `colorKey` in a palette
you supply. It does not know what your markers *mean* — that mapping lives in you supply. It does not know what your markers *mean* — that mapping lives in
your adapter. This is what lets one renderer serve a private map coloured by your adapter. This is what lets one renderer serve a private map coloured by
@@ -104,10 +114,18 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with.
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
src/cities/ data packs — pure geography, no code src/cities/ data packs — pure geography, no code
src/adapters/ where outside data plugs in src/adapters/ where outside data plugs in
src/tools/ instruments — god-only, dynamically imported, never statically
``` ```
`engine` never imports `cities`; neither imports `adapters`. `engine` never imports `cities`; neither imports `adapters`.
Nothing under `src/tools/` may be reached by a static import from the app. It is
loaded by one `await import()` behind `access.can.debug`, so a visitor who is
not an admin does not download the code at all — which is the strongest
available reading of "nothing here runs for a non-god visitor": not a hidden
panel, not a disabled panel, no panel. `src/tools/index.ts` states the rule and
what silently undoes it.
## Licence ## Licence
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
+301 -52
View File
@@ -2,7 +2,16 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <!--
`viewport-fit=cover` so the canvas reaches the edges of a notched screen;
every fixed card below then pays for it with an `env(safe-area-inset-*)`.
Deliberately **no** `user-scalable=no` and no `maximum-scale`: the map
itself is already protected from browser zoom by `touch-action: none` and
by `scenekit.ts` swallowing WebKit's `gesture*` events, and taking page
zoom away from everyone to protect one element is the accessibility
mistake that rule exists to avoid.
-->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<!-- <!--
An empty data URI, not an icon file. `src/` carries no binary assets by An empty data URI, not an icon file. `src/` carries no binary assets by
@@ -89,12 +98,29 @@
what people noticed. */ what people noticed. */
background: #0d1218; background: #0d1218;
overflow: hidden; overflow: hidden;
/* The canvas cannot scroll the page — `touch-action: none` sees to that
— but a drag that starts on the panel, the rail or the source line
still rubber-bands the whole document on iOS and can still trigger
pull-to-refresh on Android, which on a full-screen map looks like the
map itself has come loose. */
overscroll-behavior: none;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink); color: var(--ink);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
#scene { display: block; width: 100vw; height: 100dvh; touch-action: none; } #scene {
display: block;
width: 100vw;
height: 100dvh;
touch-action: none;
/* A slow orbit on iOS is, to WebKit, a long press on a page: it raises
the selection callout and the magnifier over the city. There is no
text here to select. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* ---- Type scale ------------------------------------------------------ /* ---- Type scale ------------------------------------------------------
Five sizes. 9px is reserved for uppercase micro-labels, where the caps Five sizes. 9px is reserved for uppercase micro-labels, where the caps
@@ -162,22 +188,10 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); } /* The `#hour` scrubber and its `now` button used to live here. They were
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; } god-only, and the godmode panel now owns the same override with a date
.scrub button { as well as an hour — two writers for one value, of which this was the
font: inherit; one that silently threw the date away. `main.ts` says the rest. */
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.scrub button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
.cities { display: flex; gap: var(--s1); } .cities { display: flex; gap: var(--s1); }
.city { .city {
@@ -195,7 +209,9 @@
color: var(--ink-2); color: var(--ink-2);
transition: background var(--t), color var(--t); transition: background var(--t), color var(--t);
} }
@media (hover: hover) {
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); } .city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
}
.city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); } .city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); }
.enter { .enter {
@@ -212,7 +228,7 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: background var(--t); transition: background var(--t);
} }
.enter:hover { background: var(--amber-lit); } @media (hover: hover) { .enter:hover { background: var(--amber-lit); } }
/* The public-office note. Amber-edged rather than amber-filled: it is an /* The public-office note. Amber-edged rather than amber-filled: it is an
explanation, not an action, and the one filled amber thing on screen explanation, not an action, and the one filled amber thing on screen
@@ -255,7 +271,9 @@
color: var(--ink-2); color: var(--ink-2);
transition: background var(--t), color var(--t); transition: background var(--t), color var(--t);
} }
@media (hover: hover) {
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); } .chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
}
.chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); } .chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; } .num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
@@ -266,7 +284,7 @@
.panel-toggle { .panel-toggle {
display: none; display: none;
position: fixed; position: fixed;
top: var(--s3); top: calc(var(--s3) + env(safe-area-inset-top));
left: var(--s3); left: var(--s3);
z-index: 4; z-index: 4;
align-items: center; align-items: center;
@@ -287,10 +305,18 @@
} }
.panel-toggle .glyph { font-size: 13px; line-height: 1; } .panel-toggle .glyph { font-size: 13px; line-height: 1; }
/* ---- Top right: the plan --------------------------------------------- */ /* ---- Top right: who you are, and the plan ------------------------------
The tier badge is its own fixed card rather than the last child of
`.corner`, which it used to be. Two reasons, and the second is a bug:
a phone turns the plan into a bottom sheet and the badge must not go
down there with it, and `M` — which hides the plan — was also hiding
the one line that says whether you are signed in. */
.corner { .corner {
position: fixed; position: fixed;
top: var(--s4); /* Clear of the tier badge above it, which is one line of 9px caps in a
card: 8 + 14 + 8 of padding and leading, two hairlines, and the 4px
rhythm's worth of gap. */
top: calc(var(--s4) + env(safe-area-inset-top) + 2.4rem);
right: var(--s4); right: var(--s4);
width: 15rem; width: 15rem;
display: flex; display: flex;
@@ -325,7 +351,12 @@
} }
.tier { .tier {
position: fixed;
top: calc(var(--s4) + env(safe-area-inset-top));
right: var(--s4);
z-index: 3;
margin: 0; margin: 0;
max-width: min(15rem, 50vw);
padding: var(--s2) var(--s3); padding: var(--s2) var(--s3);
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -336,7 +367,15 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-3); color: var(--ink-3);
} }
.tier .who { color: var(--ink-2); text-transform: none; letter-spacing: 0.04em; } .tier .who {
color: var(--ink-2);
text-transform: none;
letter-spacing: 0.04em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tier.god .who { color: var(--amber-ink); } .tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; } .tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
@@ -344,7 +383,10 @@
.rail { .rail {
position: fixed; position: fixed;
right: var(--s4); right: var(--s4);
bottom: var(--s4); /* The safe-area inset is zero everywhere except the phone it exists for,
where `.rail { bottom: 16px }` puts the `?` button underneath the home
indicator — a control you can see and cannot press. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 3; z-index: 3;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -356,7 +398,32 @@
second panel. */ second panel. */
max-width: min(30rem, calc(100vw - var(--s4) * 2)); max-width: min(30rem, calc(100vw - var(--s4) * 2));
} }
#detail { max-width: 100%; } #detail {
max-width: 100%;
display: flex;
align-items: flex-start;
gap: var(--s3);
}
#detail-text { flex: 1; min-width: 0; }
/* A dismiss the thumb can find. On a mouse the detail card is cleared by
moving the pointer off the marker, which is an affordance touch simply
does not have: a tap sets it and nothing takes it away. Tapping empty
map clears it too — this is the version you can see. */
.detail-close {
flex: none;
font: inherit;
font-size: 14px;
line-height: 1;
padding: 0 var(--s1);
margin: -2px 0;
cursor: pointer;
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--ink-3);
transition: color var(--t);
}
@media (hover: hover) { .detail-close:hover { color: var(--ink); } }
.hint { .hint {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -377,6 +444,19 @@
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink-2); color: var(--ink-2);
} }
/* The plan toggle is for pointers, and only for pointers.
Where there is a keyboard the `M` hint two elements to the left is the
affordance and a button saying the same thing is a second row of glass
for nothing. Where there is not — a phone, a tablet, a touchscreen
kiosk — the hints are hidden or meaningless and this is the only way in.
Keyed off the pointer rather than off the width because the defect it
fixes is about fingers: an 820px iPad has no `M` either, and it lands on
the desktop layout by design (see `deviceProfile` in `stage.ts`). */
#plan-toggle { display: none; }
@media (pointer: coarse), (max-width: 600px) {
#plan-toggle { display: inline-flex; align-items: center; }
}
.help { .help {
font: inherit; font: inherit;
font-size: 10px; font-size: 10px;
@@ -389,18 +469,21 @@
color: var(--ink-2); color: var(--ink-2);
transition: background var(--t), color var(--t); transition: background var(--t), color var(--t);
} }
@media (hover: hover) {
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); } .help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
}
/* ---- Bottom left: where the numbers came from ------------------------- /* ---- Bottom left: where the numbers came from -------------------------
Shown only when the data is live — see `renderLegend` in main.ts for why Shown only when the data is live — see `renderSource` in main.ts for why
the sample-data half of this was retired. The disclosure it used to the sample-data half of this was retired. The disclosure it used to
carry did not go into a README nobody opens: it is on the boot card carry did not go into a README nobody opens: it is on the boot card
everyone passes through and in the `?` card, which is one keypress away everyone passes through and in the `?` card, which is one keypress away
from any state the app can be in. */ from any state the app can be in. The `?` card is also where the live
sources' own credit lines land; see `#credits`. */
.source { .source {
position: fixed; position: fixed;
left: var(--s4); left: var(--s4);
bottom: var(--s4); bottom: calc(var(--s4) + env(safe-area-inset-bottom));
margin: 0; margin: 0;
z-index: 3; z-index: 3;
max-width: calc(100vw - var(--s4) * 2); max-width: calc(100vw - var(--s4) * 2);
@@ -417,6 +500,18 @@
} }
.source.live { color: #8fe89a; } .source.live { color: #8fe89a; }
/* The scrim behind the panel sheet. Declared *before* the phone breakpoint
and not after it: two rules of equal specificity, and the last one in
the file wins whatever the media query says — which is how this spent
an afternoon being permanently invisible. */
#scrim {
display: none;
position: fixed;
inset: 0;
z-index: 5;
background: rgba(4, 7, 11, 0.45);
}
/* ---- Overlays --------------------------------------------------------- */ /* ---- Overlays --------------------------------------------------------- */
.overlay { .overlay {
position: fixed; position: fixed;
@@ -438,6 +533,8 @@
} }
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em; .sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--amber); } text-transform: uppercase; color: var(--amber); }
/* The Touch section, under the keyboard one. */
.sheet h2:not(:first-child) { margin-top: var(--s4); }
.keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3); .keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3);
align-items: baseline; font-size: 11px; } align-items: baseline; font-size: 11px; }
.keys dt { text-align: right; } .keys dt { text-align: right; }
@@ -454,7 +551,7 @@
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink); color: var(--ink);
} }
.sheet-close:hover { background: rgba(255, 255, 255, 0.16); } @media (hover: hover) { .sheet-close:hover { background: rgba(255, 255, 255, 0.16); } }
/* ---- Boot ------------------------------------------------------------- /* ---- Boot -------------------------------------------------------------
The page used to be blank for the ~2.3 s it takes to build the Bay Area The page used to be blank for the ~2.3 s it takes to build the Bay Area
@@ -505,12 +602,22 @@
.boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2); .boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2);
min-height: 1.4em; } min-height: 1.4em; }
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); } .boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
/* One notch up from the note above it, and a hairline off it. A credit
line is somebody's licence term rather than this project's small print,
and two identical grey paragraphs read as one. */
.credits {
margin-top: var(--s3);
padding-top: var(--s3);
border-top: 1px solid var(--hairline);
color: var(--ink-3);
}
/* ---- Responsive ------------------------------------------------------- /* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture Two breakpoints and no more. At 900 the left column stops being furniture
and becomes a sheet you open; at 600 the plan stops fitting beside the and becomes a sheet you open; at 600 the plan stops fitting beside the
map at all and lives behind `M`. `main.ts` owns the *state* of both, so map at all and moves to the "plan view" button on the rail (and `M`,
these rules only describe what each state looks like. */ where there is a keyboard). `main.ts` owns the *state* of both, so these
rules only describe what each state looks like. */
@media (max-width: 900px) { @media (max-width: 900px) {
.panel-toggle { display: flex; } .panel-toggle { display: flex; }
#panel { #panel {
@@ -523,23 +630,131 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.corner { width: 11.5rem; top: var(--s3); right: var(--s3); } .corner {
width: 11.5rem;
top: calc(var(--s3) + env(safe-area-inset-top) + 2.4rem);
right: var(--s3);
}
.tier { top: calc(var(--s3) + env(safe-area-inset-top)); right: var(--s3); }
.minimap-frame { height: 10rem; } .minimap-frame { height: 10rem; }
.rail { right: var(--s3); bottom: var(--s3); } .rail { right: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
.source { left: var(--s3); bottom: var(--s3); } .source { left: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
} }
/* ---- The phone --------------------------------------------------------
Below 600 this stops being a scaled-down desktop and becomes a different
layout, because the constraints are different in kind and not in degree:
there is no hover, so nothing may depend on it; there is no keyboard, so
a card explaining the keyboard is furniture; and there is one thumb,
which reaches the bottom third of the screen and not the top corners.
The renderer draws the same conclusion from the same numbers —
`deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
stylesheet and the pixel budget cannot drift apart. */
@media (max-width: 600px) { @media (max-width: 600px) {
.rail { max-width: calc(100vw - var(--s3) * 2); } /* Everything hit by a finger clears 44px, which is 11 steps of the 4px
/* The provenance line and the key hints would collide in the last 40px rhythm and the smallest target anyone has managed to defend. `#help`
of a phone, and the honesty line is the one that has to survive. */ was 17px tall. */
.hint span { display: none; } #panel-toggle,
.source { max-width: 60vw; } #enter,
.city,
.chapter,
.sheet-close {
min-height: 44px;
}
.chapter { align-items: center; }
.help,
.detail-close {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border-radius: 999px;
} }
/* The minimap's own visibility is a user decision (`M`), seeded from the /* The rail spans the width so the detail card — now reachable, because
viewport width by `main.ts` rather than by a media query, so that a marker picks on tap — is a readable line rather than a column. */
toggling it on at 375px actually shows it. */ .rail {
left: var(--s3);
right: var(--s3);
max-width: none;
align-items: stretch;
}
/* No keyboard, so no key hints — and with them gone the card around
them is a full-width bar of glass holding one button. On a phone the
hint *is* the `?` button. */
.hint span { display: none; }
.hint {
align-self: flex-end;
padding: 0;
border: 0;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* The honesty line moves out of the thumb's way rather than out of the
layout. It is the one caption that is never allowed to be dropped for
space, so it goes to the quiet corner under the ☰ button and stays to
one ellipsised line. */
.source {
top: calc(var(--s3) + env(safe-area-inset-top) + 3.1rem);
bottom: auto;
max-width: min(60vw, calc(100vw - var(--s3) * 2));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The plan, as a bottom sheet. At 11.5rem in a corner it ate a fifth of
a 375px map and its own affordances were about six pixels across —
a map you cannot read to move a map you can. Full width above the
rail, it is a plan view; and it is still off until asked for, which
on this layout means the "plan view" button on the rail. It was `M`
and only `M` for a while, which meant this block was written for a
device that could never reach it. */
.corner {
top: auto;
left: var(--s3);
right: var(--s3);
width: auto;
bottom: calc(var(--s3) + 3.5rem + env(safe-area-inset-bottom));
}
.minimap-frame { height: min(38dvh, 18rem); }
/* Hover-only text on a device with no hover. */
.minimap-readout { display: none; }
/* The panel, as a bottom sheet. A left drawer on a phone covers the
thing being looked at; a sheet rises from the edge the thumb starts
at, and the scrim behind it means tapping the map closes it. */
#panel {
inset: auto 0 0 0;
width: auto;
max-width: none;
max-height: 72dvh;
padding: var(--s4) var(--s3) calc(var(--s4) + env(safe-area-inset-bottom));
gap: var(--s2);
z-index: 6;
pointer-events: auto;
background: var(--glass-strong);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border-top: 1px solid var(--hairline);
border-radius: var(--r) var(--r) 0 0;
box-shadow: 0 -8px 30px rgba(3, 6, 10, 0.5);
}
body.panel-closed #panel { transform: translateY(100%); opacity: 0; }
#scrim { display: block; }
body.panel-closed #scrim { display: none; }
}
/* The minimap's own visibility is a user decision (`M`, or the "plan view"
button on the rail), seeded from the viewport width by `main.ts` rather
than by a media query, so that toggling it on at 375px actually shows
it. */
body.minimap-off .corner { display: none; } body.minimap-off .corner { display: none; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -564,11 +779,6 @@
<h1 id="title">San Francisco</h1> <h1 id="title">San Francisco</h1>
<p id="subtitle">Tera · Lumbridge Simulate</p> <p id="subtitle">Tera · Lumbridge Simulate</p>
<p id="clock" class="clock" aria-live="polite"></p> <p id="clock" class="clock" aria-live="polite"></p>
<div id="scrub" class="scrub" hidden>
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12"
aria-label="Hour of day" />
<button id="now" title="follow the wall clock">now</button>
</div>
</section> </section>
<nav id="cities" class="cities" aria-label="City"></nav> <nav id="cities" class="cities" aria-label="City"></nav>
<button id="enter" class="enter">Enter the office →</button> <button id="enter" class="enter">Enter the office →</button>
@@ -577,27 +787,45 @@
<p class="card" id="blurb"></p> <p class="card" id="blurb"></p>
</div> </div>
<p class="card tier" id="tier" hidden></p>
<aside id="corner" class="corner" aria-label="Plan view"> <aside id="corner" class="corner" aria-label="Plan view">
<div class="card" id="minimap"> <div class="card" id="minimap">
<div class="minimap-frame"></div> <div class="minimap-frame"></div>
<p id="minimap-readout" class="minimap-readout"></p> <p id="minimap-readout" class="minimap-readout"></p>
</div> </div>
<p class="card tier" id="tier" hidden></p>
</aside> </aside>
<div class="rail" id="rail"> <div class="rail" id="rail">
<div class="card" id="detail" aria-live="polite" hidden></div> <div class="card" id="detail" aria-live="polite" hidden>
<span id="detail-text"></span>
<button id="detail-close" class="detail-close" aria-label="Dismiss">×</button>
</div>
<div class="card hint" id="hint"> <div class="card hint" id="hint">
<span><kbd>1</kbd><kbd>9</kbd> chapters</span> <span><kbd>1</kbd><kbd>9</kbd> chapters</span>
<span><kbd>[</kbd> <kbd>]</kbd> city</span> <span><kbd>[</kbd> <kbd>]</kbd> city</span>
<span><kbd>O</kbd> office</span> <span><kbd>O</kbd> office</span>
<span><kbd>M</kbd> plan</span> <span><kbd>M</kbd> plan</span>
<!-- The plan view's tap target, for the devices where `M` is not a
thing that exists. Until this button did, the plan was seeded off
below 600px and the key was the only way to turn it on, so the
bottom-sheet layout designed for it further up this file was
unreachable on the device it was drawn for. Hidden where the key
hint beside it is true; see `#plan-toggle` in the stylesheet.
`aria-pressed` and no second label, because the state it reports is
the map appearing next to it. -->
<button id="plan-toggle" class="help" aria-pressed="true">plan view</button>
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button> <button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
</div> </div>
</div> </div>
<p id="source" class="source"></p> <p id="source" class="source"></p>
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
scrim leaves no way to dismiss it but the same button that opened it,
which is the wrong half of the screen for a thumb. -->
<div id="scrim"></div>
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true" <div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
aria-labelledby="shortcuts-title" hidden> aria-labelledby="shortcuts-title" hidden>
<div class="card sheet"> <div class="card sheet">
@@ -607,15 +835,36 @@
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd> <dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd> <dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd> <dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
<dt><kbd>?</kbd></dt><dd>This card</dd> <dt id="key-overlays"><kbd>?</kbd></dt><dd>This card</dd>
<dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd> <dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd>
<dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd> <dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd>
</dl> </dl>
<p class="boot-note">On the plan: click or drag to move the view, scroll to zoom, <p class="boot-note">On the plan: click or drag to move the view, scroll to zoom,
arrow keys to aim and <kbd>Enter</kbd> to go.</p> arrow keys to aim and <kbd>Enter</kbd> to go.</p>
<h2 id="touch-title">Touch</h2>
<dl class="keys">
<dt>One finger</dt><dd>Orbit</dd>
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
</dl>
<!-- Stated as the rule rather than as a claim about this page, because
this file is static and the answer is not: one build serves a
deployment with a weather station and one with nothing at all. -->
<p class="boot-note">The markers on this map are fabricated. No real company data <p class="boot-note">The markers on this map are fabricated. No real company data
ships in this build; a deployment wired to a live source says <em>live data</em> ships in this build. A deployment wired to a live source names it in the corner —
in the corner, and this one does not.</p> <em>live weather</em>, <em>live traffic</em>, <em>live markers</em>, or
<em>live data</em> when all three are. Nothing in the corner means everything you
are looking at came out of this bundle.</p>
<!-- Credit for whatever live source is answering right now, written by
`renderCredits` in main.ts. Empty and hidden on a build with no
server, which is most of them — and not decoration when it is not:
MET Norway and Open-Meteo are CC BY 4.0 and the API forwards the
line each of them asks for. This card is where it is shown because
it is reachable from every state on both layouts, and because the
corner line is clamped to one ellipsised phrase on a phone. -->
<p class="boot-note credits" id="credits" hidden></p>
<button class="sheet-close" id="shortcuts-close">Close</button> <button class="sheet-close" id="shortcuts-close">Close</button>
</div> </div>
</div> </div>
+93 -10
View File
@@ -23,23 +23,72 @@ without breaking the typecheck, which is the wrong order to find out.
## Routes ## Routes
| route | body | cached | | route | query | body | cached |
| --- | --- | --- | | --- | --- | --- | --- |
| `GET /api/v1/health` | `HealthBody` | never | | `GET /api/v1/health` | — | `HealthBody` | never |
| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` | | `GET /api/v1/flights` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `FlightsBody` | public, `TERA_FLIGHTS_TTL` |
| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` | | `GET /api/v1/weather` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `WeatherBody` | public, `TERA_WEATHER_TTL` |
| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` | | `GET /api/v1/markers` | — | `MarkersBody` | **private; 401 unless signed in** — public empty body when `TERA_MARKERS_SOURCE=none` |
| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only | | `GET /api/v1/offices/:id` | — | `OfficeDoc` | public offices only |
Every body is declared once, in `src/server/wire.ts` in the **root** package — Every body is declared once, in `src/server/wire.ts` in the **root** package —
type-only, so it compiles to nothing and both the browser build and this service type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other. import the same declarations without either becoming a dependency of the other.
Both location parameters are optional and omitting them answers for the default
region, which is the first entry in `TERA_REGIONS`. Giving both `city` and a
coordinate is a 400, as is a coordinate this deployment has nothing to say
about; see **Regions** below for why that is a refusal and not a lookup.
`radiusNm` is accepted on `/flights` and deliberately ignored — `routes/flights.ts`
explains what a caller-chosen cache key would dissolve.
`Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every `Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every
reply before any route runs, and a route opts in explicitly. A request that reply before any route runs, and a route opts in explicitly. A request that
arrived with an `Authorization` header or a cookie never gets a public policy, arrived with an `Authorization` header or a cookie never gets a public policy,
whatever the route asked for. whatever the route asked for.
## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the
points the operator configured.** Weather and flights answer for a *resolved
region*, and a request for somewhere this box does not serve is a 400 naming
what it does.
That is an allowlist rather than a lookup because the obvious version — take
`?lat=&lng=` and hand it to NWS — turns an unauthenticated endpoint into a free
geocoding proxy for the planet: an amplifier pointed at somebody else's
public-good API, from an address they will blame, with the operator's own
contact string on every request. It is also what bounds everything downstream,
since the upstream key space *is* the region list: the per-region caches, the
NWS station cache and the adsb.lol poll budget are all bounded by the
environment file and cannot be grown by anybody sending requests.
(`src/regions.ts` has the full reasoning, including why snapping to a coarse
grid was rejected.)
| variable | default | what it does |
| --- | --- | --- |
| `TERA_REGIONS` | *(empty)* | `id:lat,lng[:radiusKm]`, separated by `;` or newlines. The list, in the operator's order; the first is the default. |
```ini
TERA_REGIONS=sf:37.7749,-122.4194;socal:33.82,-118.05:150
```
Ids are the same ones the browser's city packs use. `radiusKm` defaults to 120,
which covers both shipped boards with room to spare and leaves them disjoint. A
malformed entry is dropped with a line in `degraded`, and a spec in which
nothing parses falls back to the shipped pair — a typo is a demotion, never a
refusal to boot.
Left empty, the box serves the two cities the map ships with. An operator who
pointed `TERA_ORIGIN_LAT/_LNG` somewhere else additionally gets that point as a
region named `origin`, first in the list and therefore the default, so a bare
`GET /api/v1/weather` on their box answers exactly as it did before regions
existed.
`GET /api/v1/health` publishes the resolved list as `regions`, in the same
order, so a client can pick its default the way the server does instead of
guessing a `?city=` and getting a 400 it cannot explain.
## Configuration ## Configuration
Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A
@@ -53,7 +102,7 @@ missing weather contact string; this is the correction. (CONTRACT.md §5.1.)
| `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. | | `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. |
| `TERA_PORT` | `8431` | | | `TERA_PORT` | `8431` | |
| `TERA_LOG_LEVEL` | `info` | | | `TERA_LOG_LEVEL` | `info` | |
| `TERA_ORIGIN_LAT` / `_LNG` | SF | The city this box serves. Weather point and flight-plan centre. | | `TERA_ORIGIN_LAT` / `_LNG` | SF | Default region only, and superseded entirely by `TERA_REGIONS`. Nothing per-request reads it. |
| `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. | | `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. |
| `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. | | `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. |
@@ -83,9 +132,9 @@ is a supported steady state, not an error path.
| --- | --- | --- | | --- | --- | --- |
| `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. | | `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. |
| `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. | | `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. |
| `TERA_ADSB_RADIUS_NM` | `40` | | | `TERA_ADSB_RADIUS_NM` | `40` | Clamped to 1250 nm, which is what both feeds accept, with a `degraded` line. |
| `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. | | `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. |
| `TERA_FLIGHTS_TTL` | `300` | Clamped to 15 s for live sources. | | `TERA_FLIGHTS_TTL` | `300` | For live sources, clamped **into 515 s**. |
| `TERA_FLIGHTS_SEED` | `4711` | | | `TERA_FLIGHTS_SEED` | `4711` | |
The simulated source is served as a **route plan**, not as positions: the routes, The simulated source is served as a **route plan**, not as positions: the routes,
@@ -93,6 +142,17 @@ a fixed phase origin and a seed, which every browser evaluates in closed form
against wall-clock time. One cacheable request replaces a poll per second, and against wall-clock time. One cacheable request replaces a poll per second, and
two people on different machines see the same aircraft in the same places. two people on different machines see the same aircraft in the same places.
The **floor** under the live TTL is the load-bearing half of that clamp, and it
is why the cell above reads as a range. adsb.lol asks for no more than one
request per second and airplanes.live publishes the same ceiling; both are
volunteer-fed. `TERA_FLIGHTS_TTL=0` used to mean one upstream request per
inbound request — the exact flood the limit exists to stop, delivered by a
setting that reads like "as fresh as possible". With the floor the worst case is
arithmetic rather than a guess: **regions ÷ 5 requests per second** with every
region under continuous load, which for the two shipped here is 0.4/s. An
operator configuring more than five regions and keeping them all warm is the
case to watch.
There is no FlightRadar24 client and there will not be one — their terms forbid There is no FlightRadar24 client and there will not be one — their terms forbid
scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would
be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box
@@ -112,6 +172,29 @@ The API serves a file. It holds no database and no credential, and private
per-user markers are never proxied through it — an authenticated browser calls per-user markers are never proxied through it — an authenticated browser calls
Workie directly with its own token, so a private row never enters this process. Workie directly with its own token, so a private row never enters this process.
**Where a marker feed is configured, it takes a session.** This is the one route
the `member` tier is about, and until it refused somebody the tier was a word in
a type union that no server behaviour corresponded to. An anonymous caller gets
401 with `WWW-Authenticate: Bearer`; a member gets the snapshot with no public
cache policy on it, because a body that took a credential to obtain must not sit
in a shared cache waiting for the next caller. There is deliberately no
`TERA_MARKERS_PUBLIC` escape hatch. (401 rather than the 404 an office answers
with: nothing here is enumerable — one feed, one path, and `/api/v1/health`
already publishes `sources.markers` — so the caller is told the useful thing,
which is "sign in and ask again".)
Two consequences worth knowing before you configure it:
- **A box with no feed still answers 200 and an empty list.** `source: none` is
the zero-config default, there is nothing there to protect, and making a
stranger sign in to be told "no markers" would fail the acceptance test at the
top of this file.
- **`TERA_MARKERS_SOURCE=file` with `TERA_AUTH_MODE=none` is unreachable by
everyone**, because nobody on such a box is ever authenticated. That is the
fail-closed direction and it is the one private offices already take; the
config pushes a line into `degraded` saying so, rather than letting it be
discovered from an empty map.
**Every row must declare where its coordinate came from, and the gate refuses **Every row must declare where its coordinate came from, and the gate refuses
anything not on the allowlist.** Serving a snapshot of geocoded coordinates is anything not on the allowlist.** Serving a snapshot of geocoded coordinates is
Public Use of a Derivative Database; if those coordinates came from Nominatim, Public Use of a Derivative Database; if those coordinates came from Nominatim,
+61 -8
View File
@@ -19,6 +19,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import { loadRegions, type RegionSet } from "./regions.ts";
import type { import type {
AuthMode, AuthMode,
FlightsSourceId, FlightsSourceId,
@@ -114,8 +115,17 @@ export interface Config {
port: number; port: number;
logLevel: string; logLevel: string;
version: string; version: string;
/** The city this box is serving, in degrees. Used by weather and by flights. */ /**
* The default place this box serves, in degrees.
*
* Kept because it is what a self-hoster already wrote in their env file, and
* because `regions[0]` is derived from it. Nothing per-request reads it any
* more: weather and flights answer for a *resolved region*, since one origin
* cannot describe two cities six hundred kilometres apart. See `regions.ts`.
*/
origin: { lat: number; lng: number }; origin: { lat: number; lng: number };
/** Every place this box will answer for. The first one is the default. */
regions: RegionSet;
/** Allowed CORS origins. Empty means same-origin only, which is the default. */ /** Allowed CORS origins. Empty means same-origin only, which is the default. */
corsOrigins: string[]; corsOrigins: string[];
/** `max-age` for routes that opt in to public caching. */ /** `max-age` for routes that opt in to public caching. */
@@ -136,18 +146,29 @@ export function loadConfig(env: Env = process.env): Config {
const weather = loadWeather(env, degraded); const weather = loadWeather(env, degraded);
const flights = loadFlights(env, degraded); const flights = loadFlights(env, degraded);
const markers = loadMarkers(env, degraded);
const auth = loadAuth(env, degraded); const auth = loadAuth(env, degraded);
// After auth, because a marker feed with nobody able to sign in is worth a
// sentence and the sentence is only true once `mode` has finished demoting.
const markers = loadMarkers(env, auth.mode, degraded);
const origin = {
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded),
};
return { return {
host: str(env, "TERA_HOST", "127.0.0.1"), host: str(env, "TERA_HOST", "127.0.0.1"),
port: num(env, "TERA_PORT", 8431, degraded), port: num(env, "TERA_PORT", 8431, degraded),
logLevel: str(env, "TERA_LOG_LEVEL", "info"), logLevel: str(env, "TERA_LOG_LEVEL", "info"),
version: readVersion(), version: readVersion(),
origin: { origin,
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded), regions: loadRegions({
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded), spec: str(env, "TERA_REGIONS", ""),
}, origin,
originConfigured:
str(env, "TERA_ORIGIN_LAT", "") !== "" || str(env, "TERA_ORIGIN_LNG", "") !== "",
degraded,
}),
corsOrigins: list(env, "TERA_CORS_ORIGIN"), corsOrigins: list(env, "TERA_CORS_ORIGIN"),
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded), publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
weather, weather,
@@ -236,7 +257,7 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
return { return {
source, source,
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"), endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
radiusNm: num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
dump1090Path, dump1090Path,
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded), seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded),
@@ -244,12 +265,31 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
}; };
} }
/**
* The largest circle the hosted feeds will answer for: adsb.lol and
* airplanes.live both cap `/v2/point/:lat/:lng/:radius` at 250 nautical miles
* and reject anything larger outright. Clamping here rather than letting the
* request fail keeps the failure legible — an operator who typed 2500 gets a map
* with aircraft on it and a sentence in `degraded`, not a silently empty sky.
*/
const MAX_ADSB_RADIUS_NM = 250;
function radius(asked: number, degraded: string[]): number {
if (asked >= 1 && asked <= MAX_ADSB_RADIUS_NM) return asked;
const clamped = Math.min(MAX_ADSB_RADIUS_NM, Math.max(1, asked));
degraded.push(
`TERA_ADSB_RADIUS_NM=${asked} is outside the 1${MAX_ADSB_RADIUS_NM} nautical ` +
`miles the hosted feeds answer for; using ${clamped}.`,
);
return clamped;
}
const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"]; const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];
/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */ /** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"]; export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"];
function loadMarkers(env: Env, degraded: string[]): MarkersConfig { function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersConfig {
const asked = str(env, "TERA_MARKERS_SOURCE", "none"); const asked = str(env, "TERA_MARKERS_SOURCE", "none");
let source = oneOf(asked, MARKER_SOURCES); let source = oneOf(asked, MARKER_SOURCES);
if (source === null) { if (source === null) {
@@ -268,6 +308,19 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
source = "none"; source = "none";
} }
// Not a demotion — the feed stays configured and the route keeps refusing
// correctly — but an operator who mounted a marker file on a box where nobody
// can sign in has built something no browser will ever see, and one sentence
// now is cheaper than an afternoon. `routes/markers.ts` has the reasoning for
// why the feed is members-only with no public escape hatch.
if (source === "file" && authMode === "none") {
degraded.push(
"TERA_MARKERS_SOURCE=file needs an authentication mode: the marker feed is " +
"refused to anonymous callers, and with TERA_AUTH_MODE=none nobody is ever " +
"anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE.",
);
}
const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST"); const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST");
return { return {
source, source,
+82 -5
View File
@@ -38,15 +38,36 @@ export interface FlightsSnapshot {
observedAt: number; observedAt: number;
} }
/** Just enough of the service's logger to say a feed came back oversized. */
export interface AdsbLog {
warn(msg: string): void;
}
/**
* One circle of sky, from a hosted feed.
*
* `center` is a **region centre from `regions.ts`**, never a coordinate off the
* wire. That is the whole reason the routes validate before they get here: this
* function will happily fetch anywhere, and the thing standing between it and
* being an open proxy pointed at a community feed is that its callers can only
* hand it points an operator configured. The radius is clamped to the 250 nm
* both feeds accept, in `config.ts`.
*
* How often this is allowed to be called, and the arithmetic that keeps it
* inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`.
*/
export async function fetchAdsb( export async function fetchAdsb(
endpoint: string, endpoint: string,
center: { lat: number; lng: number }, center: { lat: number; lng: number },
radiusNm: number, radiusNm: number,
log?: AdsbLog,
): Promise<FlightsSnapshot | null> { ): Promise<FlightsSnapshot | null> {
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
const body = await getJson<AircraftEnvelope>(url); const body = await getJson<AircraftEnvelope>(url);
if (body === null) return null; if (body === null) return null;
return normalise(body); return normalise(body, (dropped) =>
log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`),
);
} }
/** /**
@@ -57,19 +78,75 @@ export async function fetchAdsb(
* returns `null` and lets the caller keep the previous snapshot instead of * returns `null` and lets the caller keep the previous snapshot instead of
* emptying the sky for one tick. * emptying the sky for one tick.
*/ */
export async function readDump1090(path: string): Promise<FlightsSnapshot | null> { export async function readDump1090(path: string, log?: AdsbLog): Promise<FlightsSnapshot | null> {
try { try {
const text = await readFile(path, "utf8"); const text = await readFile(path, "utf8");
return normalise(JSON.parse(text) as AircraftEnvelope); return normalise(JSON.parse(text) as AircraftEnvelope, (dropped) =>
log?.warn(
`flights:dump1090: ${path} held ${dropped + MAX_ROWS} aircraft; ` +
`kept the first ${MAX_ROWS}`,
),
);
} catch { } catch {
return null; return null;
} }
} }
function normalise(body: AircraftEnvelope): FlightsSnapshot { /**
const rows = body.ac ?? body.aircraft ?? []; * How many aircraft one snapshot may carry.
*
* Not a limit anybody should ever meet: a 250 nm circle over the busiest
* airspace on earth is a few thousand contacts, and the board draws a dart for
* each. It is here because the row count is set by somebody else's server and
* the parsed array is held in this process and served, cached, to every
* anonymous caller for the length of the TTL — a feed answering with 200,000
* rows was measured at a 14.9 MB public body. The excess is dropped and
* counted rather than silently trimmed, so an operator whose sky is suddenly
* capped finds out from the log rather than from a map that looks thin.
*/
const MAX_ROWS = 5000;
/**
* One envelope, turned into a snapshot — or `null` if it is not an envelope.
*
* ### Checked, not assumed
*
* This was `body.ac ?? body.aircraft ?? []` followed by a `for…of`, so a
* well-formed JSON 200 with a number in `ac` threw "rows is not iterable" out
* of `fetchAdsb` — past `upstream.ts`, which then neither stamped its clock nor
* caught it, and out to the route as a 500. `markers/gate.ts` has always done
* this for its own input; the hosted-feed path was the one that trusted the
* wire.
*
* ### Why an unreadable body is `null` and not an empty sky
*
* `?? []` would still be here and would still not throw, and it would be the
* wrong answer. `flights/index.ts` reads `null` as "fall back to the plan" and
* reads a snapshot — *including an empty one* — as observed truth, which is
* correct: three in the morning over a small city really is an empty sky. A
* garbled body coerced to zero aircraft is therefore served as `mode: "live"`
* with nothing in it, and the operator who has just turned on ADS-B gets a
* blank map that claims to be real. That is precisely the outcome the top of
* this file says the fallback exists to prevent.
*
* So the test is on the *array*, not on its length: a body carrying `ac: []`
* is an empty circle and is live; a body carrying neither `ac` nor `aircraft`
* as an array is not an answer at all.
*/
function normalise(
body: AircraftEnvelope,
onDrop?: (dropped: number) => void,
): FlightsSnapshot | null {
const raw = body.ac ?? body.aircraft;
if (!Array.isArray(raw)) return null;
const rows = raw.length > MAX_ROWS ? raw.slice(0, MAX_ROWS) : raw;
if (raw.length > rows.length) onDrop?.(raw.length - rows.length);
const aircraft: WireAircraft[] = []; const aircraft: WireAircraft[] = [];
for (const a of rows) { for (const a of rows) {
// A row that is not an object at all reaches this from the same wire that
// sent a number where the array was.
if (a === null || typeof a !== "object") continue;
if (typeof a.lat !== "number" || typeof a.lon !== "number") continue; if (typeof a.lat !== "number" || typeof a.lon !== "number") continue;
const callsign = a.flight?.trim(); const callsign = a.flight?.trim();
const id = a.hex ?? callsign; const id = a.hex ?? callsign;
+75 -29
View File
@@ -1,5 +1,5 @@
/** /**
* Which sky this box serves. * Which sky this box serves, and for which city.
* *
* The simulated source answers from a plan and never touches the network, so it * The simulated source answers from a plan and never touches the network, so it
* is free and it is the default. The two real sources are polled on a timer and * is free and it is the default. The two real sources are polled on a timer and
@@ -7,15 +7,48 @@
* answering serves its last snapshot, and a feed that has never answered falls * answering serves its last snapshot, and a feed that has never answered falls
* back to the plan rather than to an empty sky. An operator who turned on ADS-B * back to the plan rather than to an empty sky. An operator who turned on ADS-B
* and got a blank map would reasonably conclude the renderer was broken. * and got a blank map would reasonably conclude the renderer was broken.
*
* Everything here is **per region**. `TERA_ADSB_RADIUS_NM` around one origin
* could only ever describe one city, and the Bay Area wants SFO, OAK and SJC
* while the Southland wants LAX, BUR, LGB and SNA — six hundred kilometres of
* empty coastline apart, with no sane radius that covers both. The requested
* region supplies the centre; the radius stays what the operator configured.
*
* ### Staying inside adsb.lol's limits
*
* adsb.lol asks for **no more than one request per second** from a client and
* says outright that it will rate-limit callers who ignore it; airplanes.live,
* which serves the same shape at `TERA_ADSB_ENDPOINT`, publishes the same
* ceiling. Both are volunteer-fed community feeds paid for by people who did not
* sign up to host somebody's map.
*
* This stays inside the limit structurally rather than by hoping:
*
* - One poll per region per `liveTtl`, never per request. Concurrent misses on a
* region collapse into one upstream call (`upstream.ts`).
* - `liveTtl` is **floored at 5 seconds** as well as capped at 15. The floor is
* the load-bearing half and it is new: `TERA_FLIGHTS_TTL=0` used to mean one
* upstream request per inbound request, which is exactly the flood the limit
* exists to stop, delivered by a config value that reads like "as fresh as
* possible".
* - The region set is a fixed allowlist from the environment, so the worst case
* is arithmetic rather than a guess: **regions ÷ 5 requests per second**, with
* every region under continuous load. With the two this repo ships that is
* 0.4/s against a ceiling of 1/s. An operator who configures more than five
* regions and keeps them all warm is the case to watch, and writing the number
* down here is how they find that out before adsb.lol does.
* - A box nobody is looking at polls nothing at all.
*/ */
import type { Config } from "../config.ts"; import type { Config } from "../config.ts";
import type { Region } from "../regions.ts";
import type { FlightsBody } from "../../../src/server/wire.ts"; import type { FlightsBody } from "../../../src/server/wire.ts";
import { createUpstream } from "../upstream.ts";
import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts"; import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts";
import { planFor } from "./plan.ts"; import { planFor } from "./plan.ts";
export interface FlightsService { export interface FlightsService {
current(): Promise<FlightsBody>; current(region: Region): Promise<FlightsBody>;
} }
export interface FlightsLog { export interface FlightsLog {
@@ -24,48 +57,61 @@ export interface FlightsLog {
/** /**
* How long a live snapshot may be cached. Aircraft move; the plan does not, so * How long a live snapshot may be cached. Aircraft move; the plan does not, so
* only the live path is clamped. * only the live path is clamped. See the rate-limit note above for the floor.
*/ */
const LIVE_MAX_TTL_SECONDS = 15; const LIVE_MAX_TTL_SECONDS = 15;
const LIVE_MIN_TTL_SECONDS = 5;
/**
* The one cache key every `dump1090` region shares.
*
* A receiver has one antenna. Asking a Bay Area rooftop for the Southland gets
* the Bay Area sky whatever the query said, so keying this per region would
* multiply the file reads without changing a byte of the answer. The honest
* thing is one key and a snapshot whose aircraft carry their own coordinates —
* the renderer puts them where they actually are.
*/
const RECEIVER_KEY = "receiver";
export function createFlightsService(config: Config, log: FlightsLog): FlightsService { export function createFlightsService(config: Config, log: FlightsLog): FlightsService {
const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights; const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights;
const routes = planFor(config.origin);
const plan = (): FlightsBody => ({ // Built once per region and kept: the plan is a pure function of the centre,
// and it is handed out on every cacheable request.
const plans = new Map<string, FlightsBody>();
const plan = (region: Region): FlightsBody => {
const existing = plans.get(region.id);
if (existing !== undefined) return existing;
const body: FlightsBody = {
mode: "plan", mode: "plan",
source: "sim", source: "sim",
t0: epochMs, t0: epochMs,
seed, seed,
routes, routes: planFor(region),
ttlSeconds, ttlSeconds,
};
plans.set(region.id, body);
return body;
};
const liveTtl = Math.min(LIVE_MAX_TTL_SECONDS, Math.max(LIVE_MIN_TTL_SECONDS, ttlSeconds));
const upstream = createUpstream<FlightsSnapshot>({
label: `flights:${source}`,
ttlSeconds: liveTtl,
log,
}); });
const liveTtl = Math.min(ttlSeconds, LIVE_MAX_TTL_SECONDS);
let snapshot: FlightsSnapshot | null = null;
let polledAt = 0;
async function poll(): Promise<void> {
const fresh =
source === "dump1090"
? await readDump1090(dump1090Path)
: await fetchAdsb(endpoint, config.origin, radiusNm);
polledAt = Date.now();
if (fresh !== null) {
snapshot = fresh;
return;
}
log.warn(
`flights: ${source} did not answer; serving ${snapshot === null ? "the simulated plan" : "the last snapshot"}`,
);
}
return { return {
async current(): Promise<FlightsBody> { async current(region: Region): Promise<FlightsBody> {
if (source === "sim") return plan(); if (source === "sim") return plan(region);
if (Date.now() - polledAt > liveTtl * 1000) await poll(); const key = source === "dump1090" ? RECEIVER_KEY : region.id;
if (snapshot === null) return plan(); const snapshot = await upstream.get(key, () =>
source === "dump1090"
? readDump1090(dump1090Path, log)
: fetchAdsb(endpoint, region, radiusNm, log),
);
if (snapshot === null) return plan(region);
return { return {
mode: "live", mode: "live",
+107 -17
View File
@@ -11,11 +11,12 @@
* *
* ### Where the coordinates came from * ### Where the coordinates came from
* *
* The three Bay Area airport positions are published FAA airport reference * The seven airport positions — SFO, OAK and SJC in the north, LAX, BUR, LGB and
* points, typed in by hand. They are US government facts in the public domain, * SNA in the south — are published FAA airport reference points, typed in by
* and — as with every other coordinate in this repo — emphatically not derived * hand. They are US government facts in the public domain, and — as with every
* from OpenStreetMap. Everything else here is a waypoint someone made up so the * other coordinate in this repo — emphatically not derived from OpenStreetMap.
* legs go the right way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8. * Everything else here is a waypoint someone made up so the legs go the right
* way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8.
* *
* The callsigns are invented, with operator prefixes that belong to nobody. A * The callsigns are invented, with operator prefixes that belong to nobody. A
* repo that refuses to ship other people's logos should not ship their flight * repo that refuses to ship other people's logos should not ship their flight
@@ -95,17 +96,100 @@ const BAY_AREA: WireSimRoute[] = [
}, },
]; ];
/** Degrees, roughly the distance from downtown SF to the far end of the bay. */ const LAX: [number, number] = [33.9425, -118.4081];
const BAY_AREA_RADIUS = 0.75; const BUR: [number, number] = [34.2007, -118.3585];
const LGB: [number, number] = [33.8177, -118.1516];
const SNA: [number, number] = [33.6757, -117.8682];
/**
* The Southland, which is a different shape of airspace and not a translated
* copy of the Bay Area.
*
* Almost everything here runs eastwest, because the terrain does: the basin is
* walled by the San Gabriels to the north, so departures go out over the water
* and turn, and arrivals come down the length of the valley. Four fields instead
* of three, and the two small ones matter more than they do in the north — a
* board four times the area at a third of the scale reads as empty very fast if
* everything is at cruise.
*/
const SOCAL: WireSimRoute[] = [
// Departures — the standard west-over-the-ocean climb, the north-east haul
// over the Cajon Pass, and the two valley fields going their own way.
{ callsign: "LMB604", from: LAX, to: [33.6, -119.3], fromAlt: 20, toAlt: 10500, duration: 440 },
{ callsign: "PAC712", from: LAX, to: [34.6, -117.1], fromAlt: 20, toAlt: 11500, duration: 520 },
{ callsign: "SIE288", from: BUR, to: [34.9, -118.9], fromAlt: 20, toAlt: 9800, duration: 460 },
{ callsign: "BAY1516", from: SNA, to: [33.1, -118.6], fromAlt: 20, toAlt: 9600, duration: 420 },
{ callsign: "GLD843", from: LGB, to: [34.5, -116.9], fromAlt: 20, toAlt: 10200, duration: 500 },
// Arrivals — the long straight-in from the east, the coastal descent from the
// north-west, and one down the back of the mountains into Burbank.
{ callsign: "LMB1170", from: [34.1, -116.8], to: LAX, fromAlt: 6200, toAlt: 20, duration: 560 },
{ callsign: "RDW425", from: [34.7, -119.4], to: LAX, fromAlt: 6800, toAlt: 20, duration: 600 },
{ callsign: "PAC96", from: [34.8, -117.6], to: BUR, fromAlt: 5400, toAlt: 20, duration: 480 },
{ callsign: "SIE1901", from: [33.2, -117.2], to: SNA, fromAlt: 5000, toAlt: 20, duration: 450 },
// Overflights, level the whole way: the coastal corridor and the desert one.
{
callsign: "GLD1330",
from: [34.9, -119.2],
to: [32.9, -117.0],
fromAlt: 11200,
toAlt: 11200,
duration: 690,
},
{
callsign: "RDW78",
from: [33.0, -119.1],
to: [34.9, -116.8],
fromAlt: 10600,
toAlt: 10600,
duration: 720,
},
// Low across the basin. General aviation is most of what anybody standing in
// Culver City actually sees, and it is the only traffic that reads as *near*.
{
callsign: "BAY3312",
from: [33.78, -118.42],
to: [34.16, -117.75],
fromAlt: 850,
toAlt: 850,
duration: 540,
},
{
callsign: "LMB2044",
from: [34.22, -118.72],
to: [33.72, -117.98],
fromAlt: 1300,
toAlt: 1300,
duration: 570,
},
];
/**
* The airspaces this file has actually laid out, and how close a region's centre
* has to be to get one.
*
* Degrees rather than kilometres because the comparison is a box, not a circle,
* and the radii are generous: the Bay Area's is roughly downtown to the far end
* of the bay, and the Southland's spans a basin that runs from Ventura to
* Riverside. A region centre that lands inside one of these is close enough that
* the real airports are the right answer; anything else gets spokes.
*/
const AIRSPACES: { centre: [number, number]; radius: number; routes: WireSimRoute[] }[] = [
{ centre: [37.7749, -122.4194], radius: 0.75, routes: BAY_AREA },
{ centre: [33.82, -118.05], radius: 1.0, routes: SOCAL },
];
/** /**
* A plan for a city this file has never heard of. * A plan for a city this file has never heard of.
* *
* A self-hoster pointing `TERA_ORIGIN_LAT/LNG` at somewhere that is not San * A self-hoster pointing `TERA_REGIONS` (or `TERA_ORIGIN_LAT/LNG`) at somewhere
* Francisco should get moving aircraft rather than an empty sky, so eight legs * that is neither of the two boards should get moving aircraft rather than an
* are laid out on evenly-spaced bearings through their origin. It is not their * empty sky, so eight legs are laid out on evenly-spaced bearings through the
* city's real airspace and does not pretend to be — it is motion in the right * region centre. It is not their city's real airspace and does not pretend to be
* kind of place, which is all the map ever wanted from this. * — it is motion in the right kind of place, which is all the map ever wanted
* from this.
*/ */
function genericPlan(lat: number, lng: number): WireSimRoute[] { function genericPlan(lat: number, lng: number): WireSimRoute[] {
const routes: WireSimRoute[] = []; const routes: WireSimRoute[] = [];
@@ -127,9 +211,15 @@ function genericPlan(lat: number, lng: number): WireSimRoute[] {
return routes; return routes;
} }
export function planFor(origin: { lat: number; lng: number }): WireSimRoute[] { export function planFor(centre: { lat: number; lng: number }): WireSimRoute[] {
const nearSf = for (const airspace of AIRSPACES) {
Math.abs(origin.lat - 37.7749) < BAY_AREA_RADIUS && const [lat, lng] = airspace.centre;
Math.abs(origin.lng + 122.4194) < BAY_AREA_RADIUS; if (
return nearSf ? BAY_AREA : genericPlan(origin.lat, origin.lng); Math.abs(centre.lat - lat) < airspace.radius &&
Math.abs(centre.lng - lng) < airspace.radius
) {
return airspace.routes;
}
}
return genericPlan(centre.lat, centre.lng);
} }
+66 -3
View File
@@ -9,28 +9,91 @@
const DEFAULT_TIMEOUT_MS = 6000; const DEFAULT_TIMEOUT_MS = 6000;
/**
* The largest upstream body this service will read.
*
* "Every outbound call is bounded" was true of the *time* and not of the size:
* a bare `res.json()` reads whatever arrives, and what arrives is chosen by
* somebody else's server. An ADS-B endpoint answering with 200,000 aircraft was
* measured at 14.9 MB, parsed into an array this process then held and served
* — cached, publicly — to every anonymous caller for the length of the TTL.
*
* Four megabytes is roughly two orders of magnitude above any honest answer
* from the four upstreams here (a busy adsb.lol circle is tens of kilobytes, an
* NWS observation is under ten) and well under anything that would trouble the
* heap. It bounds the damage; `flights/adsb.ts` caps the row count, which
* bounds what is kept.
*/
const MAX_BODY_BYTES = 4 * 1024 * 1024;
export interface GetJsonOptions { export interface GetJsonOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
timeoutMs?: number; timeoutMs?: number;
/** Body-size ceiling in bytes. Defaults to `MAX_BODY_BYTES`. */
maxBytes?: number;
} }
/** /**
* `null` on any failure at all — transport, status, or unparseable body. The * `null` on any failure at all — transport, status, an oversized body, or one
* caller decides what a missing answer means; nothing here does. * that will not parse. The caller decides what a missing answer means; nothing
* here does.
*/ */
export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> { export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES;
try { try {
const res = await fetch(url, { const res = await fetch(url, {
headers: { accept: "application/json", ...opts.headers }, headers: { accept: "application/json", ...opts.headers },
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
}); });
if (!res.ok) return null; if (!res.ok) return null;
return (await res.json()) as T;
/**
* The header first, because it is free and it is the one that stops the
* transfer before it happens. It is only advisory — a chunked response
* sends none — so the body is counted as it streams as well, and the
* `cancel()` closes the socket on a server that lied or did not say.
*/
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
await res.body?.cancel();
return null;
}
const text = await readBounded(res, maxBytes);
if (text === null) return null;
return JSON.parse(text) as T;
} catch { } catch {
return null; return null;
} }
} }
/** The body as text, or `null` the moment it goes over `maxBytes`. */
async function readBounded(res: Response, maxBytes: number): Promise<string | null> {
const body = res.body;
// Undici always gives a stream; a test double or a `fetch` polyfill may not,
// and falling back to `res.text()` there is still bounded by the header check
// above and by the timeout.
if (!body) {
const text = await res.text();
return text.length > maxBytes ? null : text;
}
const reader = body.getReader();
const decoder = new TextDecoder();
let size = 0;
let out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel();
return null;
}
out += decoder.decode(value, { stream: true });
}
return out + decoder.decode();
}
/** /**
* A User-Agent that identifies this software and the operator running it. * A User-Agent that identifies this software and the operator running it.
* *
+86 -10
View File
@@ -9,9 +9,42 @@
* Private per-user markers never appear here at all. An authenticated browser * Private per-user markers never appear here at all. An authenticated browser
* calls Workie directly with its own token, so private rows never transit this * calls Workie directly with its own token, so private rows never transit this
* process. CONTRACT.md §5. * process. CONTRACT.md §5.
*
* ### What the file looks like
*
* ```json
* { "generatedAt": "2026-08-05T09:00:00Z",
* "markers": [
* { "id": "ferry-building", "label": "Ferry Building", "colorKey": "sector.civic",
* "lat": 37.7955, "lng": -122.3937, "provenance": "hand-placed" }
* ] }
* ```
*
* A bare array is accepted too, because it is the obvious thing to hand-write
* and a self-hoster's first marker file should not need a wrapper object. Every
* row goes through `gate.ts`, which refuses — row by row, never repairing —
* anything carrying a field nobody reviewed and anything whose `provenance` is
* not on the allowlist. The counts come back on the wire in `refused`, because a
* silent drop looks exactly like an empty database.
*
* ### Reloading without a restart
*
* `TERA_MARKERS_TTL` is the reload interval, not just a cache lifetime: once it
* expires, the next request `stat`s the file and re-reads it only if the mtime
* or the size moved. That is what makes a short TTL affordable — five seconds on
* an unchanged file costs one `stat` every five seconds — so `npm run sync`
* followed by its atomic rename is visible on the map within seconds, with
* nothing restarted and nothing signalled.
*
* The other half is that **a read that fails keeps the last good snapshot**.
* This used to replace the cache with an empty body, so a single unreadable read
* — the file being rewritten by something less careful than the sync oneshot, a
* permissions change, a full disk — emptied the map for a whole TTL. A stale
* marker set is a far cheaper mistake than a map that quietly lost its markers,
* which is the same trade the sync oneshot makes when it refuses to write.
*/ */
import { readFile } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import type { Config } from "../config.ts"; import type { Config } from "../config.ts";
import type { MarkersBody } from "../../../src/server/wire.ts"; import type { MarkersBody } from "../../../src/server/wire.ts";
import { assertPublicShape } from "./gate.ts"; import { assertPublicShape } from "./gate.ts";
@@ -29,24 +62,43 @@ interface Snapshot {
markers?: unknown; markers?: unknown;
} }
/**
* What a configured-but-unreadable file serves before it has ever been read.
*
* It goes out as a refusal rather than as a plain empty list on purpose: an
* operator looking at `/api/v1/markers` and seeing nothing is owed the
* difference between "there are no markers" and "this box cannot read the file
* you pointed it at". Same reasoning as the gate's counts.
*/
function unreadable(file: string): MarkersBody {
return {
markers: [],
generatedAt: new Date().toISOString(),
refused: [{ reason: `the snapshot at ${file} could not be read`, count: 1 }],
};
}
export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore { export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore {
const { source, file, provenanceAllowlist, ttlSeconds } = config.markers; const { source, file, provenanceAllowlist, ttlSeconds } = config.markers;
let cached: MarkersBody | null = null; let cached: MarkersBody | null = null;
let readAt = 0; let readAt = 0;
/** mtime and size of the file `cached` was built from. Empty means "unknown". */
let signature = "";
async function load(): Promise<MarkersBody> { /** `null` when the file could not be read or parsed; the caller keeps what it has. */
const now = new Date().toISOString(); async function load(): Promise<MarkersBody | null> {
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(await readFile(file, "utf8")); parsed = JSON.parse(await readFile(file, "utf8"));
} catch (err) { } catch (err) {
log.warn(`markers: cannot read ${file} (${String(err)}); serving no markers`); log.warn(
return { markers: [], generatedAt: now, refused: [] }; `markers: cannot read ${file} (${String(err)}); serving ` +
`${cached === null ? "no markers" : "the last snapshot"}`,
);
return null;
} }
// A bare array is accepted because it is the obvious thing to hand-write,
// and a self-hoster's first marker file should not need a wrapper object.
const snapshot: Snapshot = const snapshot: Snapshot =
Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot); Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot);
@@ -57,21 +109,45 @@ export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore {
return { return {
markers: accepted, markers: accepted,
generatedAt: snapshot.generatedAt ?? now, generatedAt: snapshot.generatedAt ?? new Date().toISOString(),
refused, refused,
}; };
} }
/**
* Whether the file on disk differs from the one behind `cached`.
*
* A failed `stat` answers "yes", so the read is attempted and reports the real
* error once, rather than this function inventing a reason of its own.
*/
async function changed(): Promise<boolean> {
try {
const info = await stat(file);
const next = `${info.mtimeMs}:${info.size}`;
if (next === signature) return false;
signature = next;
return true;
} catch {
signature = "";
return true;
}
}
return { return {
async current(): Promise<MarkersBody> { async current(): Promise<MarkersBody> {
if (source === "none") { if (source === "none") {
return { markers: [], generatedAt: new Date().toISOString(), refused: [] }; return { markers: [], generatedAt: new Date().toISOString(), refused: [] };
} }
if (cached === null || Date.now() - readAt > ttlSeconds * 1000) { if (cached === null || Date.now() - readAt > ttlSeconds * 1000) {
cached = await load(); if (cached === null || (await changed())) {
const fresh = await load();
if (fresh !== null) cached = fresh;
}
readAt = Date.now(); readAt = Date.now();
} }
return cached;
return cached ?? unreadable(file);
}, },
}; };
} }
+332
View File
@@ -0,0 +1,332 @@
/**
* The places this deployment will answer for, and the refusal of everywhere else.
*
* `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred
* kilometres apart with genuinely different skies — the marine-layer comment in
* `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own
* weather, not San Francisco's fog. So weather and flights have to answer for a
* *requested* place rather than for the box's one origin.
*
* ### Why this is an allowlist and not a lookup
*
* The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an
* unauthenticated endpoint into a free geocoding proxy for the whole planet.
* Two things go wrong with that, and neither is hypothetical. It is an
* amplification vector: one cheap request here becomes one expensive request to
* somebody else's public-good API, from an address they will blame. And it is
* how a deployment's User-Agent gets blocked, because NWS's fair-use policy is
* written against exactly this pattern and the contact string in
* `TERA_WEATHER_CONTACT` is the operator's own name on the request.
*
* The rule here is therefore: **a caller's coordinate is never forwarded
* upstream. It only selects among the points the operator configured.** A
* request for Berkeley resolves to the San Francisco region and fetches San
* Francisco's centre; a request for Fresno is refused with a 400 naming what is
* served. The upstream key space is the region list, so it is bounded by the
* environment file and cannot be grown by anybody sending requests — which is
* the property that makes per-region caching, the NWS station cache and the
* adsb.lol poll budget in `flights/adsb.ts` all bounded too.
*
* A coarse grid was the other candidate and was rejected: snapping to 0.5° still
* leaves a caller able to name a hundred thousand distinct cells, which bounds
* nothing that matters. Refusing is the honest answer, and a self-hoster who
* wants their own city writes one line of `TERA_REGIONS`.
*/
/** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */
const KM_PER_DEGREE = 111.195;
/**
* How far from a region's centre a request may land and still resolve there,
* when the operator did not say.
*
* 120 km covers both shipped boards with room to spare — the far corner of the
* Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving
* the two regions comfortably disjoint, since their centres are about 440 km
* apart. It is deliberately not tight: the point of the radius is to refuse
* somewhere this deployment has nothing to say about, not to police the edge of
* the rendered board.
*/
export const DEFAULT_RADIUS_KM = 120;
export interface Region {
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
id: string;
lat: number;
lng: number;
/** Kilometres. See `DEFAULT_RADIUS_KM`. */
radiusKm: number;
}
/**
* At least one region, always, with the first one being the default.
*
* A tuple rather than an array because "the default region" is read on every
* request that omits a query, and a `Region | undefined` there would be a
* falsehood the type system made everybody handle.
*/
export type RegionSet = [Region, ...Region[]];
/**
* The two cities this repo ships, with the centres their packs declare —
* `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`.
*
* They are restated rather than imported for the same reason `wire.ts` restates
* `SimRoute`: a city pack is three thousand lines of coastline for a renderer
* that owns three.js, and the API has one runtime dependency and intends to keep
* it. Two numbers each, and the day a pack moves its centre the weather resolves
* to a point a few kilometres off, which is a rounding error against a radius of
* 120 km.
*/
const SHIPPED: Region[] = [
{ id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM },
{ id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM },
];
/**
* `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar.
*
* The decimal places are capped in the pattern rather than checked afterwards
* because it is the same cap the query parser applies, and the two agreeing is
* the point: a coordinate nobody could ask for is a coordinate nobody should be
* able to configure either.
*/
const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/;
export interface LoadRegionsOptions {
/** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */
spec: string;
/** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */
origin: { lat: number; lng: number };
/** Whether the operator actually wrote an origin, as opposed to inheriting SF. */
originConfigured: boolean;
degraded: string[];
}
/**
* The region set for this box.
*
* Three cases, in order, and the ordering is what keeps every existing
* deployment answering exactly as it did:
*
* 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order.
* 2. Otherwise the two shipped cities, because that is what the bundled map
* draws and serving only one of them is wrong for half the board.
* 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere
* else gets that point as a region of its own, first in the list and
* therefore the default. A bare `GET /api/v1/weather` on their box answers
* for their origin, exactly as it did before this file existed. If their
* origin already falls inside a shipped region, that region moves to the
* front instead of being duplicated.
*
* Malformed entries are dropped with a line in `degraded` rather than being
* fatal, and a spec in which *nothing* parses falls all the way back to case 2.
* CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot.
*/
export function loadRegions(opts: LoadRegionsOptions): RegionSet {
const configured = parseSpec(opts.spec, opts.degraded);
if (configured.length > 0) return asSet(configured, configured[0] as Region);
const shipped = SHIPPED.map((region) => ({ ...region }));
const containing = shipped.findIndex((region) => contains(region, opts.origin));
if (containing > 0) {
const moved = shipped[containing] as Region;
shipped.splice(containing, 1);
shipped.unshift(moved);
} else if (containing === -1 && opts.originConfigured) {
shipped.unshift({
id: "origin",
lat: opts.origin.lat,
lng: opts.origin.lng,
radiusKm: DEFAULT_RADIUS_KM,
});
}
return asSet(shipped, shipped[0] as Region);
}
/**
* `Region[]` to `RegionSet`, with the caller supplying the head it has already
* proved is there. The alternative is a cast on the whole array, which would
* also silence the empty case this type exists to rule out.
*/
function asSet(regions: Region[], head: Region): RegionSet {
return [head, ...regions.slice(1)];
}
function parseSpec(spec: string, degraded: string[]): Region[] {
const trimmed = spec.trim();
if (trimmed === "") return [];
const regions: Region[] = [];
for (const raw of trimmed.split(/[;\n]/)) {
const entry = raw.trim();
if (entry === "") continue;
const match = ENTRY.exec(entry);
if (match === null) {
degraded.push(
`TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` +
`\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`,
);
continue;
}
const id = match[1] as string;
const lat = Number(match[2]);
const lng = Number(match[3]);
const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]);
if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) {
degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`);
continue;
}
if (regions.some((region) => region.id === id)) {
degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`);
continue;
}
regions.push({ id, lat, lng, radiusKm });
}
if (regions.length === 0 && trimmed !== "") {
degraded.push(
"TERA_REGIONS was set but nothing in it parsed; serving the two cities the " +
"map ships with instead.",
);
}
return regions;
}
// ---- Answering a request --------------------------------------------------
/**
* What a route hands in. `unknown` throughout because this is untrusted query
* input: Fastify hands back a string for `?lat=1`, an **array** for
* `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed
* `string` would be a lie the first time somebody repeated a parameter.
*/
export interface RegionQuery {
city?: unknown;
lat?: unknown;
lng?: unknown;
}
export type RegionResolution =
| { ok: true; region: Region }
/** Already a sentence. The route sends it as `ErrorBody.message`. */
| { ok: false; message: string };
/** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */
const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/;
const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
/**
* Turn a query into one of the configured regions, or into a refusal.
*
* Every branch that is not a resolved region is a **400**, not a degraded body.
* That is the one place this file departs from the "degrade, never fail"
* convention, and the distinction is who made the mistake: an upstream that is
* down is not the caller's fault and must not become their problem, whereas
* `?lat=banana` is a client bug that a 200 full of clear sky would hide until
* somebody wondered why the fog never rolls in.
*/
export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution {
const city = query.city;
const hasCity = city !== undefined && city !== "";
const hasLat = query.lat !== undefined && query.lat !== "";
const hasLng = query.lng !== undefined && query.lng !== "";
if (hasCity && (hasLat || hasLng)) {
return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." };
}
if (hasCity) {
if (typeof city !== "string" || !CITY_ID.test(city)) {
return { ok: false, message: `city must be one of: ${served(regions)}.` };
}
const region = regions.find((candidate) => candidate.id === city);
if (region === undefined) {
return { ok: false, message: `This deployment serves: ${served(regions)}.` };
}
return { ok: true, region };
}
if (hasLat !== hasLng) {
return { ok: false, message: "lat and lng have to be given together." };
}
if (!hasLat) return { ok: true, region: regions[0] };
const lat = degrees(query.lat, 90);
const lng = degrees(query.lng, 180);
if (lat === null || lng === null) {
return {
ok: false,
message:
"lat and lng must be plain decimal degrees within ±90 and ±180, " +
"with at most six decimal places.",
};
}
const region = nearest(regions, lat, lng);
if (region === null) {
return {
ok: false,
message:
`Nothing this deployment serves is near ${lat},${lng}. It serves: ` +
`${served(regions)}. Ask for one of those by id, or add yours to ` +
`TERA_REGIONS on the server.`,
};
}
return { ok: true, region };
}
function served(regions: RegionSet): string {
return regions.map((region) => region.id).join(", ");
}
function degrees(raw: unknown, limit: number): number | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
// The pattern is doing the work `Number()` would do badly: it rejects `NaN`,
// `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which
// `Number()` either accepts or turns into a value that then has to be
// re-checked. One regex, one meaning.
if (!DEGREES.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
}
/** The closest region that claims the point, or `null` if none of them does. */
function nearest(regions: RegionSet, lat: number, lng: number): Region | null {
let best: Region | null = null;
let bestKm = Infinity;
for (const region of regions) {
const km = distanceKm(region, lat, lng);
if (km <= region.radiusKm && km < bestKm) {
best = region;
bestKm = km;
}
}
return best;
}
/**
* Equirectangular rather than haversine, because at these distances the error is
* under half a percent and the comparison it feeds is against a radius chosen to
* the nearest ten kilometres. Trigonometry accurate to the metre would be
* decorating a threshold that is deliberately fuzzy.
*/
function distanceKm(region: Region, lat: number, lng: number): number {
const dLat = lat - region.lat;
const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180;
const dLng = (lng - region.lng) * Math.cos(meanLat);
return Math.hypot(dLat, dLng) * KM_PER_DEGREE;
}
function contains(region: Region, point: { lat: number; lng: number }): boolean {
return distanceKm(region, point.lat, point.lng) <= region.radiusKm;
}
+35 -5
View File
@@ -1,18 +1,48 @@
/** /**
* `GET /api/v1/flights`. * `GET /api/v1/flights` — for a city, not for the box.
*
* `?city=socal`, or `?lat=&lng=` resolved against the same allowlist the weather
* route uses, or neither for the default region. `regions.ts` owns the
* validation, the refusal, and the reasoning behind refusing at all: a live
* traffic endpoint that will fetch any coordinate on demand is an amplifier
* pointed at a volunteer-funded feed.
* *
* Publicly cacheable, because the whole design of the plan is that one response * Publicly cacheable, because the whole design of the plan is that one response
* serves every viewer for its whole TTL. Aircraft are not personal data and this * serves every viewer of a region for its whole TTL. Aircraft are not personal
* body never varies by who asked. * data and this body never varies by who asked — only by where.
*
* ### `radiusNm` on the query is ignored, deliberately
*
* The browser sends one. It is dropped, and the size of the circle stays
* `TERA_ADSB_RADIUS_NM`, for a reason that is not stubbornness: the cache key
* would have to include it, and a caller who can choose the key can make this
* box hold an unbounded number of entries and issue an unbounded number of
* distinct upstream requests — each one more expensive than the last, since a
* wider circle is more work for the feed to answer. Every bound in
* `flights/index.ts` and `regions.ts` rests on the key space being the operator's
* region list, and a query parameter that widens it dissolves all of them.
*
* An operator whose board is bigger than the circle raises
* `TERA_ADSB_RADIUS_NM`; 60 nm covers both shipped cities. The client already
* discards aircraft outside the region it drew, so a circle that is too large
* costs a little bandwidth and nothing else.
*/ */
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts"; import { publicCache } from "../cache.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts"; import type { Services } from "../services.ts";
export function registerFlights(app: FastifyInstance, services: Services): void { export function registerFlights(app: FastifyInstance, services: Services): void {
app.get("/api/v1/flights", async (req, reply) => { app.get<{ Querystring: RegionQuery }>("/api/v1/flights", async (req, reply) => {
const body = await services.flights.current(); const resolved = resolveRegion(services.config.regions, req.query);
if (!resolved.ok) {
const error: ErrorBody = { error: "bad_request", message: resolved.message };
return reply.code(400).send(error);
}
const body = await services.flights.current(resolved.region);
publicCache(req, reply, body.ttlSeconds); publicCache(req, reply, body.ttlSeconds);
return body; return body;
}); });
+25 -1
View File
@@ -10,17 +10,37 @@
* `degraded` is what makes it more than a liveness probe: every demotion the * `degraded` is what makes it more than a liveness probe: every demotion the
* config made is printed here, so "why is the weather always clear" has an * config made is printed here, so "why is the weather always clear" has an
* answer that does not require log access. * answer that does not require log access.
*
* `regions` joins it for the same reason. Weather and flights now refuse a place
* this box does not serve, so a client that guesses `?city=` and a 400 it cannot
* explain is the failure this field prevents: ask health once, learn what may be
* asked for, and an operator diagnosing "why is there no SoCal weather" reads
* the answer instead of the env file. Publishing the allowlist gives nothing
* away — knowing what is served is not the same as widening it, and the ids are
* the names of the cities the map already draws.
*/ */
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Region } from "../regions.ts";
import type { HealthBody } from "../../../src/server/wire.ts"; import type { HealthBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts"; import type { Services } from "../services.ts";
/**
* `HealthBody` plus the served regions.
*
* The field belongs in `src/server/wire.ts` beside the body it extends, and it
* is stated here only because that file is the browser side of this change and
* lands with it. Fold `regions: Region[]` into `HealthBody` and this alias goes
* away; nothing else has to move, because the shape is already exactly what the
* route serves.
*/
type HealthBodyWithRegions = HealthBody & { regions: Region[] };
export function registerHealth(app: FastifyInstance, services: Services): void { export function registerHealth(app: FastifyInstance, services: Services): void {
const { config, startedAt } = services; const { config, startedAt } = services;
app.get("/api/v1/health", async () => { app.get("/api/v1/health", async () => {
const body: HealthBody = { const body: HealthBodyWithRegions = {
ok: true, ok: true,
service: "tera-api", service: "tera-api",
version: config.version, version: config.version,
@@ -36,6 +56,10 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
? config.auth.entryUrl ? config.auth.entryUrl
: null, : null,
}, },
// In the config's order, so the first entry is the region a request with
// no query gets. A client reading this can pick its default the same way
// the server does.
regions: config.regions,
degraded: config.degraded, degraded: config.degraded,
}; };
return body; return body;
+54 -5
View File
@@ -1,24 +1,73 @@
/** /**
* `GET /api/v1/markers`. * `GET /api/v1/markers` — the one route the `member` tier is about.
* *
* The public snapshot, and nothing else. Private per-user markers are never * The public snapshot, and nothing else. Private per-user markers are never
* proxied through this box — an authenticated browser calls Workie directly with * proxied through this box — an authenticated browser calls Workie directly with
* its own token, so a private row never enters this process and cannot leave it. * its own token, so a private row never enters this process and cannot leave it.
* CONTRACT.md §5. * CONTRACT.md §5. Every row served here has been through the provenance gate in
* `markers/gate.ts`, which is what keeps a public snapshot from quietly becoming
* a Publicly Used Derivative Database under ODbL.
* *
* Every row served here has been through the provenance gate in `markers/gate.ts`, * ### Why a configured feed is refused to anonymous callers
* which is what keeps a public snapshot from quietly becoming a Publicly Used *
* Derivative Database under ODbL. * `src/access.ts` has three tiers and, until this route, the middle one gated
* nothing at all: `member` was a word in a type union that no server behaviour
* corresponded to. A tier that never refuses anybody anything is not a tier, and
* one that lives only in the client is worse — it is a UI hiding a control over
* a body the API hands to whoever asks. **So: where a marker feed is configured,
* it takes a session.** That refusal is the whole of what makes membership real,
* it is enforced here rather than drawn in the browser, and there is
* deliberately no `TERA_MARKERS_PUBLIC` escape hatch to undo it.
*
* Two consequences worth stating out loud:
*
* - **A box with no feed still answers 200 and an empty list.** `source: none`
* is the zero-config default and there is nothing there to protect; making a
* stranger sign in to be told "no markers" would fail the acceptance test in
* `boot.test.ts` and gain nobody anything.
* - **A feed with `TERA_AUTH_MODE=none` is unreachable by everyone**, because
* nobody on such a box is ever authenticated. That is the fail-closed
* direction, it is the one private offices already take, and `config.ts`
* pushes a line into `degraded` saying so rather than letting an operator
* discover it from an empty map.
*
* ### 401 here, 404 for an office
*
* `offices.ts` answers 404 for a private office because a 403 there is an
* enumeration oracle — walk the id space, read the status codes, learn every
* tenant. Nothing is enumerable here: there is one feed, at a fixed path, and
* `/api/v1/health` already publishes `sources.markers`, so whether this
* deployment has markers is not a secret being kept. What the caller needs to
* know is "sign in and ask again", which is what 401 with `WWW-Authenticate`
* says. A 404 would be a lie told to protect nothing.
*/ */
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts"; import { publicCache } from "../cache.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts"; import type { Services } from "../services.ts";
const UNAUTHORIZED: ErrorBody = {
error: "unauthorized",
message: "The marker feed is for signed-in members.",
};
export function registerMarkers(app: FastifyInstance, services: Services): void { export function registerMarkers(app: FastifyInstance, services: Services): void {
app.get("/api/v1/markers", async (req, reply) => { app.get("/api/v1/markers", async (req, reply) => {
if (services.config.markers.source === "none") {
const body = await services.markers.current(); const body = await services.markers.current();
publicCache(req, reply, services.config.markers.ttlSeconds); publicCache(req, reply, services.config.markers.ttlSeconds);
return body; return body;
}
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
// No `publicCache`. This body took a credential to obtain, and a shared
// cache holding it would hand one member's copy to the next caller — the
// exact thing the fail-closed default in `cache.ts` exists to prevent.
return services.markers.current();
}); });
} }
+28 -7
View File
@@ -1,19 +1,40 @@
/** /**
* `GET /api/v1/weather`. * `GET /api/v1/weather` — for a place, not for the box.
* *
* Always 200, always a body. A source that is down, misconfigured or absent * `?city=sf`, or `?lat=&lng=` which resolves to whichever configured region
* produces `synthetic: true` and a clear day — there is no failure mode here in * claims the point, or neither, which answers for the default region. The
* which the caller has to decide what to render, because the answer to "what is * validation and the refusal both live in `regions.ts`; the paragraph there on
* the sky doing" is never allowed to be a 503. * why this is an allowlist rather than a lookup is the one worth reading before
* touching this file.
*
* Two kinds of answer, and the split is deliberate:
*
* - **A bad request is a 400.** `?lat=banana`, or a coordinate in a city this
* deployment does not serve, is a client bug, and a 200 full of clear sky
* would hide it until somebody wondered why the fog never rolled in.
* - **Everything else is a 200 with a body.** A source that is down,
* misconfigured or absent produces `synthetic: true` and a clear day. There is
* no failure mode in which the caller has to decide what to render, because
* the answer to "what is the sky doing" is never allowed to be a 503.
*/ */
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts"; import { publicCache } from "../cache.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts"; import type { Services } from "../services.ts";
export function registerWeather(app: FastifyInstance, services: Services): void { export function registerWeather(app: FastifyInstance, services: Services): void {
app.get("/api/v1/weather", async (req, reply) => { app.get<{ Querystring: RegionQuery }>("/api/v1/weather", async (req, reply) => {
const body = await services.weather.current(); const resolved = resolveRegion(services.config.regions, req.query);
if (!resolved.ok) {
const error: ErrorBody = { error: "bad_request", message: resolved.message };
return reply.code(400).send(error);
}
const body = await services.weather.current(resolved.region);
// Query strings are part of a shared cache's key, so two regions cannot
// collide here and no extra `Vary` is owed.
publicCache(req, reply, services.config.weather.ttlSeconds); publicCache(req, reply, services.config.weather.ttlSeconds);
return body; return body;
}); });
+257
View File
@@ -0,0 +1,257 @@
/**
* Live traffic, and the budget that keeps it welcome.
*
* adsb.lol is a volunteer-fed community feed that asks for no more than one
* request per second and will rate-limit a caller who ignores it. Most of this
* file is therefore about **how often this server is capable of calling out**,
* not about what comes back: one poll per region per TTL, a TTL with a floor
* under it, and a refused query that costs the upstream nothing. The arithmetic
* those tests pin down is written out in `flights/index.ts`.
*
* The one that is not about rate is the URL assertion. A caller's coordinate
* must select a configured region and must never itself be fetched.
*/
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";
const realFetch = globalThis.fetch;
let calls: string[] = [];
let feedIsUp = true;
/** A body to serve instead of the usual one, for the wrong-shape tests. */
let feedOverride: unknown = undefined;
/** One aircraft, whose id encodes the point that was asked about. */
function feed(url: string): unknown | undefined {
if (!feedIsUp) return undefined;
const point = /\/v2\/point\/(-?[\d.]+)\/(-?[\d.]+)\/(\d+)$/.exec(url);
if (point === null) return undefined;
if (feedOverride !== undefined) return feedOverride;
return {
now: 1_770_000_000_000,
ac: [{ hex: `a${point[1]}`, flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }],
};
}
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
calls.push(url);
const body = feed(url);
if (body === undefined) return new Response("nope", { status: 503 });
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
feedIsUp = true;
feedOverride = undefined;
});
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return buildApp(config);
}
const adsbEnv = { TERA_FLIGHTS_SOURCE: "adsb" };
describe("the ADS-B source", () => {
it("polls each city's own centre, and never the caller's coordinate", async () => {
const app = appWith(adsbEnv);
after(() => app.close());
// Pasadena, which is inside the Southland region and is not a point this
// deployment serves.
await app.inject({ method: "GET", url: "/api/v1/flights?lat=34.1478&lng=-118.1445" });
await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" });
assert.deepEqual(calls, [
"https://api.adsb.lol/v2/point/33.8200/-118.0500/40",
"https://api.adsb.lol/v2/point/37.7749/-122.4194/40",
]);
});
it("clamps a radius the feeds would reject, and says so", async () => {
const config = loadConfig({ ...adsbEnv, TERA_ADSB_RADIUS_NM: "2500" });
config.logLevel = "silent";
const app = buildApp(config);
after(() => app.close());
assert.equal(config.flights.radiusNm, 250);
assert.match(config.degraded[0] ?? "", /TERA_ADSB_RADIUS_NM/);
await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.equal(calls[0], "https://api.adsb.lol/v2/point/37.7749/-122.4194/250");
});
it("polls once per region per TTL, whatever the request rate is", async () => {
const app = appWith(adsbEnv);
after(() => app.close());
const ask = (city: string) =>
app.inject({ method: "GET", url: `/api/v1/flights?city=${city}` });
await Promise.all([ask("sf"), ask("sf"), ask("socal"), ask("sf"), ask("socal")]);
for (let i = 0; i < 6; i++) await ask("sf");
// Two regions, eleven requests, two upstream calls.
assert.equal(calls.length, 2);
});
it("floors the poll interval so TERA_FLIGHTS_TTL=0 is not a flood", async () => {
// This is the bug the floor exists for: a zero TTL used to mean one
// outbound request per inbound request, straight through the rate limit,
// from a setting that reads like "as fresh as possible".
const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "0" });
after(() => app.close());
let body: FlightsBody | null = null;
for (let i = 0; i < 8; i++) {
body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
}
assert.equal(calls.length, 1);
assert.ok(body !== null && body.mode === "live" && body.ttlSeconds === 5);
});
it("caps the poll interval too, because aircraft move", async () => {
const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "3600" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live" && body.ttlSeconds === 15);
});
it("costs the upstream nothing when the query is refused", async () => {
const app = appWith(adsbEnv);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/flights?city=atlantis" });
assert.equal(res.statusCode, 400);
assert.deepEqual(calls, []);
});
it("falls back to the simulated plan rather than to an empty sky", async () => {
feedIsUp = false;
const app = appWith(adsbEnv);
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(body.mode, "plan");
assert.ok(body.mode === "plan" && body.routes.length > 0);
});
it("credits the feed it took the positions from", async () => {
const app = appWith(adsbEnv);
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live");
assert.match(body.attribution?.[0] ?? "", /adsb\.lol/);
});
});
/**
* The failure that turns a rate limit inside out.
*
* A feed that goes *down* is handled everywhere. A feed that stays up and
* answers 200 with a field of the wrong type used to throw out of `normalise`,
* past `upstream.ts` — which then never stamped its clock — and out to the
* route as a 500. The TTL is the only rate limit on outbound calls, so losing it
* meant one request to adsb.lol per inbound request, from the operator's
* address, for as long as the feed stayed broken.
*/
describe("a feed that changed shape", () => {
it("answers with the plan instead of a 500", async () => {
// Valid JSON, wrong shape: `ac` is a number where an array belongs.
feedOverride = { ac: 5, now: 1 };
const app = appWith(adsbEnv);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.equal(res.statusCode, 200);
assert.equal(res.json<FlightsBody>().mode, "plan");
});
it("still polls once per TTL, which is the whole of the rate limit", async () => {
feedOverride = { ac: 5, now: 1 };
const app = appWith(adsbEnv);
after(() => app.close());
for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.equal(calls.length, 1);
});
it("survives rows that are not objects", async () => {
feedOverride = { ac: [null, 7, "LMB1", { hex: "ok", lat: 37.5, lon: -122.3 }], now: 1 };
const app = appWith(adsbEnv);
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live" && body.aircraft.length === 1);
});
it("caps how much of somebody else's sky it will hold and serve", async () => {
feedOverride = {
now: 1,
ac: Array.from({ length: 6000 }, (_, i) => ({
hex: `x${i}`,
lat: 37.5,
lon: -122.3,
alt_baro: 1000,
})),
};
const app = appWith(adsbEnv);
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live" && body.aircraft.length === 5000);
});
});
describe("a local receiver", () => {
let path = "";
before(async () => {
const dir = await mkdtemp(join(tmpdir(), "tera-flights-"));
path = join(dir, "aircraft.json");
await writeFile(
path,
JSON.stringify({
now: 1_770_000_000,
aircraft: [{ hex: "abc123", flight: "LMB9 ", lat: 37.6, lon: -122.4, alt_baro: 3000 }],
}),
);
});
it("has one antenna, so every region reads the same snapshot once", async () => {
const app = appWith({ TERA_FLIGHTS_SOURCE: "dump1090", TERA_DUMP1090_PATH: path });
after(() => app.close());
const sf = (
await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" })
).json<FlightsBody>();
assert.ok(sf.mode === "live" && sf.aircraft.length === 1);
// Take the file away, then ask for the other city inside the TTL. A live
// answer proves the two regions share one cache entry — a per-region key
// would have gone back to disk here and found nothing.
await rm(path);
const socal = (
await app.inject({ method: "GET", url: "/api/v1/flights?city=socal" })
).json<FlightsBody>();
assert.ok(socal.mode === "live");
assert.deepEqual(socal.aircraft, sf.aircraft);
});
});
+263
View File
@@ -0,0 +1,263 @@
/**
* The marker feed: the refusal that makes the `member` tier real, and a file an
* operator can actually use.
*
* The first half is one assertion said several ways. `src/access.ts` has three
* tiers and the middle one gated nothing — `member` was a word in a type union
* with no server behaviour behind it. A tier that never refuses anybody is not a
* tier, so a configured feed takes a session, and the test that matters is the
* negative: an anonymous caller gets 401 and no rows, with no query parameter,
* header or cleared cookie that changes it.
*
* The second half is the file itself — what a malformed row does to the rest of
* the snapshot, and the reload path, which has to work without a restart because
* the sync oneshot runs on a timer and nothing signals this process.
*
* Follows `offices.test.ts`: a temp directory, `buildApp` over a fake
* environment, `inject()` rather than a socket, and HS256 by hand.
*/
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { ErrorBody, MarkersBody } from "../../../src/server/wire.ts";
const SECRET = "not-a-real-secret-and-never-was";
const FERRY = {
id: "ferry-building",
label: "Ferry Building",
colorKey: "sector.civic",
lat: 37.7955,
lng: -122.3937,
provenance: "hand-placed",
};
let file = "";
before(async () => {
const dir = await mkdtemp(join(tmpdir(), "tera-markers-"));
file = join(dir, "markers.json");
});
beforeEach(async () => {
await write({ generatedAt: "2026-08-05T09:00:00Z", markers: [FERRY] });
});
async function write(snapshot: unknown): Promise<void> {
await writeFile(file, JSON.stringify(snapshot));
}
/** A feed that is configured, with an issuer that can produce a member. */
const feedEnv = {
TERA_MARKERS_SOURCE: "file",
TERA_AUTH_MODE: "jwt",
TERA_AUTH_JWT_SECRET: SECRET,
};
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_MARKERS_FILE: file, ...env });
config.logLevel = "silent";
return buildApp(config);
}
function member(): string {
const encode = (value: unknown): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const claims = { sub: "someone", exp: Math.floor(Date.now() / 1000) + 600 };
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}
function asMember(app: ReturnType<typeof buildApp>) {
return app.inject({
method: "GET",
url: "/api/v1/markers",
headers: { authorization: `Bearer ${member()}` },
});
}
describe("a configured marker feed", () => {
it("refuses an anonymous caller", async () => {
const app = appWith(feedEnv);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
assert.equal(res.statusCode, 401);
assert.equal(res.json<ErrorBody>().error, "unauthorized");
assert.equal(res.headers["www-authenticate"], "Bearer");
// No rows anywhere in the refusal, and nothing shared may keep it.
assert.ok(!res.body.includes("Ferry"));
assert.equal(res.headers["cache-control"], "private, no-store");
});
it("refuses every shape of not-being-signed-in", async () => {
const app = appWith(feedEnv);
after(() => app.close());
const attempts = [
{},
{ authorization: "Bearer not-a-jwt" },
{ authorization: `Bearer ${member()}tampered` },
{ authorization: "Basic Zm9vOmJhcg==" },
{ cookie: "tera_session=" },
{ cookie: "tera_session=%zz" },
];
for (const headers of attempts) {
const res = await app.inject({ method: "GET", url: "/api/v1/markers", headers });
assert.equal(res.statusCode, 401, `${JSON.stringify(headers)} must not get the feed`);
}
});
it("serves the snapshot to a member", async () => {
const app = appWith(feedEnv);
after(() => app.close());
const res = await asMember(app);
assert.equal(res.statusCode, 200);
const body = res.json<MarkersBody>();
assert.equal(body.markers.length, 1);
assert.equal(body.markers[0]?.id, "ferry-building");
assert.equal(body.generatedAt, "2026-08-05T09:00:00Z");
});
it("never lets a shared cache keep a member's copy", async () => {
const app = appWith(feedEnv);
after(() => app.close());
assert.equal((await asMember(app)).headers["cache-control"], "private, no-store");
});
it("is unreachable, loudly, on a box where nobody can sign in", async () => {
const config = loadConfig({ TERA_MARKERS_FILE: file, TERA_MARKERS_SOURCE: "file" });
config.logLevel = "silent";
const app = buildApp(config);
after(() => app.close());
// Fail-closed, and it says so rather than leaving an operator to work it out
// from an empty map.
assert.equal(config.auth.mode, "none");
assert.ok(config.degraded.some((line) => line.includes("TERA_MARKERS_SOURCE=file")));
const anonymous = await app.inject({ method: "GET", url: "/api/v1/markers" });
assert.equal(anonymous.statusCode, 401);
// Even a token that would be valid elsewhere: mode=none verifies nothing.
assert.equal((await asMember(app)).statusCode, 401);
});
it("still answers the public empty body on a box with no feed", async () => {
// The acceptance test's box. There is nothing here to protect, and making a
// stranger sign in to be told "no markers" would gain nobody anything.
const app = appWith({ TERA_MARKERS_SOURCE: "none" });
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/markers" });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json<MarkersBody>().markers, []);
assert.match(String(res.headers["cache-control"]), /^public, max-age=/);
});
});
describe("the marker file itself", () => {
it("accepts a bare array, because that is what a person writes first", async () => {
await write([FERRY]);
const app = appWith(feedEnv);
after(() => app.close());
assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);
});
it("refuses a bad row without losing the good ones, and reports the count", async () => {
await write({
markers: [
FERRY,
{ ...FERRY, id: "osm-row", provenance: "nominatim" },
{ ...FERRY, id: "leaky", ownerEmail: "someone@example.com" },
{ ...FERRY, id: "broken", lat: 200 },
],
});
const app = appWith(feedEnv);
after(() => app.close());
const body = (await asMember(app)).json<MarkersBody>();
assert.deepEqual(
body.markers.map((marker) => marker.id),
["ferry-building"],
);
assert.equal(body.refused.length, 3);
assert.ok(body.refused.some((entry) => entry.reason.includes("allowlist")));
assert.ok(body.refused.some((entry) => entry.reason.includes("ownerEmail")));
});
it("picks up a rewritten file without a restart", async () => {
// TERA_MARKERS_TTL is the reload interval. Zero means every request checks,
// which is what makes this assertable without sleeping for five minutes.
const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" });
after(() => app.close());
assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);
await write({ markers: [FERRY, { ...FERRY, id: "coit-tower", label: "Coit Tower" }] });
// A zero TTL means "check on the next millisecond", not "check twice inside
// the same one" — `inject()` is fast enough that both requests can land on
// the same `Date.now()`, which is a property of the test and not of the
// reload.
await sleep(5);
const reloaded = (await asMember(app)).json<MarkersBody>();
assert.deepEqual(
reloaded.markers.map((marker) => marker.id),
["ferry-building", "coit-tower"],
);
});
it("keeps the last good snapshot when a read fails", async () => {
const app = appWith({ ...feedEnv, TERA_MARKERS_TTL: "0" });
after(() => app.close());
assert.equal((await asMember(app)).json<MarkersBody>().markers.length, 1);
// The file goes away mid-flight — a rewrite by something less careful than
// the sync oneshot, a permissions change, a full disk. Emptying the map for
// a whole TTL is the expensive mistake; serving what was there is the cheap
// one.
await rm(file);
await sleep(5);
const survived = (await asMember(app)).json<MarkersBody>();
assert.equal(survived.markers.length, 1);
// And it recovers on its own once the file comes back.
await write({ markers: [{ ...FERRY, id: "coit-tower" }] });
await sleep(5);
const recovered = (await asMember(app)).json<MarkersBody>();
assert.deepEqual(
recovered.markers.map((marker) => marker.id),
["coit-tower"],
);
});
it("says it cannot read the file rather than pretending there are no markers", async () => {
await rm(file);
const app = appWith(feedEnv);
after(() => app.close());
const body = (await asMember(app)).json<MarkersBody>();
assert.deepEqual(body.markers, []);
assert.equal(body.refused.length, 1);
assert.match(body.refused[0]?.reason ?? "", /could not be read/);
});
it("survives a file that is not JSON at all", async () => {
await writeFile(file, "{ this is not json");
const app = appWith(feedEnv);
after(() => app.close());
const res = await asMember(app);
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json<MarkersBody>().markers, []);
});
});
+280
View File
@@ -0,0 +1,280 @@
/**
* The served-region allowlist, and everything it refuses.
*
* Two halves. The first is that a box answers for the *places it was configured
* for* rather than for one origin, because the map has two cities six hundred
* kilometres apart and the Bay Area's fog is not Los Angeles's weather.
*
* The second is the one with teeth: **a caller's coordinate must never reach an
* upstream.** An endpoint that fetches any point on demand is an amplifier
* aimed at somebody else's public-good API, with this deployment's contact
* string on every request. So `?lat=&lng=` selects among configured points and
* nothing else, and everything it cannot select is a 400. See the header of
* `regions.ts`; the assertion that this refusal actually holds at the HTTP layer
* is `weather.test.ts`.
*/
import assert from "node:assert/strict";
import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { loadRegions, resolveRegion, type RegionSet } from "../regions.ts";
import type { ErrorBody, FlightsBody, WeatherBody } from "../../../src/server/wire.ts";
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return buildApp(config);
}
/** The two shipped cities, which is what an empty environment resolves to. */
function shipped(): RegionSet {
return loadConfig({}).regions;
}
describe("the region set a box ends up with", () => {
it("serves both shipped cities when handed nothing, San Francisco first", () => {
const config = loadConfig({});
assert.deepEqual(
config.regions.map((region) => region.id),
["sf", "socal"],
);
assert.deepEqual(config.degraded, []);
// The default region is still the origin a pre-existing env file named, so
// a bare GET answers exactly as it did before regions existed.
assert.equal(config.regions[0].lat, config.origin.lat);
});
it("promotes the shipped city an operator's origin falls inside", () => {
const config = loadConfig({ TERA_ORIGIN_LAT: "34.05", TERA_ORIGIN_LNG: "-118.24" });
assert.deepEqual(
config.regions.map((region) => region.id),
["socal", "sf"],
);
assert.deepEqual(config.degraded, []);
});
it("adds an origin that is neither city, and makes it the default", () => {
const config = loadConfig({ TERA_ORIGIN_LAT: "39.7392", TERA_ORIGIN_LNG: "-104.9903" });
assert.deepEqual(
config.regions.map((region) => region.id),
["origin", "sf", "socal"],
);
assert.equal(config.regions[0].lat, 39.7392);
});
it("takes TERA_REGIONS literally, in order, with an optional radius", () => {
const config = loadConfig({
TERA_REGIONS: "pdx:45.5152,-122.6784:80; sea:47.6062,-122.3321",
});
assert.deepEqual(config.regions, [
{ id: "pdx", lat: 45.5152, lng: -122.6784, radiusKm: 80 },
{ id: "sea", lat: 47.6062, lng: -122.3321, radiusKm: 120 },
]);
assert.deepEqual(config.degraded, []);
});
it("drops a malformed entry with a sentence and keeps the good ones", () => {
const config = loadConfig({ TERA_REGIONS: "pdx:45.5152,-122.6784; nowhere; sea:200,0" });
assert.deepEqual(
config.regions.map((region) => region.id),
["pdx"],
);
assert.equal(config.degraded.length, 2);
assert.match(config.degraded[0] ?? "", /nowhere/);
assert.match(config.degraded[1] ?? "", /not a place on Earth/);
});
it("falls back to the shipped cities when nothing in TERA_REGIONS parses", () => {
const config = loadConfig({ TERA_REGIONS: "?????" });
assert.deepEqual(
config.regions.map((region) => region.id),
["sf", "socal"],
);
assert.ok(config.degraded.some((line) => line.includes("nothing in it parsed")));
});
it("keeps the first of two entries sharing an id", () => {
const config = loadConfig({ TERA_REGIONS: "sf:37.7749,-122.4194; sf:0,0" });
assert.equal(config.regions.length, 1);
assert.ok(config.degraded.some((line) => line.includes("twice")));
});
it("never ends up with an empty set, whatever it was handed", () => {
for (const spec of ["", " ", ";;;", "sf:", "@:1,2", "x".repeat(200)]) {
const regions = loadRegions({
spec,
origin: { lat: 37.7749, lng: -122.4194 },
originConfigured: false,
degraded: [],
});
assert.ok(regions.length > 0, `"${spec}" produced no regions`);
}
});
});
describe("resolving a request to a region", () => {
const regions = shipped();
it("answers for the default when asked for nothing", () => {
const resolved = resolveRegion(regions, {});
assert.ok(resolved.ok && resolved.region.id === "sf");
});
it("answers for a city by id", () => {
const resolved = resolveRegion(regions, { city: "socal" });
assert.ok(resolved.ok && resolved.region.id === "socal");
});
it("snaps a nearby coordinate to the region that claims it", () => {
// Berkeley, Pasadena: neither is a configured point, and both resolve to the
// configured point that will actually be fetched.
const berkeley = resolveRegion(regions, { lat: "37.8715", lng: "-122.2730" });
assert.ok(berkeley.ok && berkeley.region.id === "sf");
const pasadena = resolveRegion(regions, { lat: "34.1478", lng: "-118.1445" });
assert.ok(pasadena.ok && pasadena.region.id === "socal");
});
it("refuses a coordinate this deployment has nothing to say about", () => {
for (const point of [
{ lat: "36.7378", lng: "-119.7871" }, // Fresno, between the two boards.
{ lat: "40.7128", lng: "-74.0060" }, // New York.
{ lat: "0", lng: "0" }, // Null Island, the classic probe.
]) {
const resolved = resolveRegion(regions, point);
assert.ok(!resolved.ok, `${point.lat},${point.lng} must be refused`);
assert.match(resolved.message, /serves: sf, socal/);
}
});
it("refuses a coordinate that is not a coordinate", () => {
for (const lat of [
"banana",
"NaN",
"Infinity",
"1e5",
"0x2f",
"37.7749deg",
"91",
"37.77490001",
"",
" ",
]) {
const resolved = resolveRegion(regions, { lat, lng: "-122.4194" });
assert.ok(!resolved.ok, `lat=${lat} must be refused`);
}
assert.ok(!resolveRegion(regions, { lat: "37.7", lng: "181" }).ok);
});
it("refuses a repeated parameter rather than picking one", () => {
// `?lat=1&lat=2` arrives as an array, and quietly taking either half is how
// a parser disagreement becomes a security bug somewhere downstream.
assert.ok(!resolveRegion(regions, { lat: ["37.7", "0"], lng: "-122.4" }).ok);
assert.ok(!resolveRegion(regions, { city: ["sf", "socal"] }).ok);
});
it("refuses half a coordinate", () => {
assert.ok(!resolveRegion(regions, { lat: "37.7749" }).ok);
assert.ok(!resolveRegion(regions, { lng: "-122.4194" }).ok);
});
it("refuses a request that asks two ways at once", () => {
const resolved = resolveRegion(regions, { city: "sf", lat: "37.7", lng: "-122.4" });
assert.ok(!resolved.ok);
assert.match(resolved.message, /not both/);
});
it("refuses an unknown city without pretending it might exist elsewhere", () => {
const resolved = resolveRegion(regions, { city: "atlantis" });
assert.ok(!resolved.ok);
assert.match(resolved.message, /sf, socal/);
});
});
describe("the routes that take a region", () => {
for (const route of ["weather", "flights"]) {
it(`serves ${route} for either city, from that city's own point`, async () => {
const app = appWith({});
after(() => app.close());
const sf = await app.inject({ method: "GET", url: `/api/v1/${route}?city=sf` });
const socal = await app.inject({ method: "GET", url: `/api/v1/${route}?city=socal` });
assert.equal(sf.statusCode, 200);
assert.equal(socal.statusCode, 200);
assert.notDeepEqual(sf.json(), socal.json());
});
it(`refuses a nonsense ${route} query with a 400 and no cached copy of it`, async () => {
const app = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: `/api/v1/${route}?lat=banana&lng=0` });
assert.equal(res.statusCode, 400);
assert.equal(res.json<ErrorBody>().error, "bad_request");
// The fail-closed default still applies: nothing shared may keep a refusal.
assert.equal(res.headers["cache-control"], "private, no-store");
});
it(`refuses a ${route} request for somewhere this box does not serve`, async () => {
const app = appWith({});
after(() => app.close());
const res = await app.inject({
method: "GET",
url: `/api/v1/${route}?lat=51.5072&lng=-0.1276`,
});
assert.equal(res.statusCode, 400);
assert.match(res.json<ErrorBody>().message, /sf, socal/);
});
}
it("puts the weather where it was asked for, not where the box is", async () => {
const app = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" });
const body = res.json<WeatherBody>();
assert.deepEqual(body.location, { lat: 33.82, lng: -118.05 });
// Still the synthetic clear day, because a zero-config box has no source.
assert.equal(body.synthetic, true);
});
it("flies the Southland's own airports over the Southland", async () => {
const app = appWith({});
after(() => app.close());
const body = (
await app.inject({ method: "GET", url: "/api/v1/flights?city=socal" })
).json<FlightsBody>();
assert.ok(body.mode === "plan");
// LAX's published reference point, as a departure or an arrival. The Bay
// Area plan cannot produce it, which is the whole point of the assertion.
const lax = body.routes.some(
(leg) =>
(leg.from[0] === 33.9425 && leg.from[1] === -118.4081) ||
(leg.to[0] === 33.9425 && leg.to[1] === -118.4081),
);
assert.ok(lax, "the SoCal plan should fly out of LAX");
});
it("lays out spokes for a city it has never heard of", async () => {
const app = appWith({ TERA_REGIONS: "pdx:45.5152,-122.6784" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "plan" && body.routes.length === 8);
});
it("publishes the allowlist on health so a client can stop guessing", async () => {
const app = appWith({});
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<{
regions: { id: string }[];
}>();
assert.deepEqual(
body.regions.map((region) => region.id),
["sf", "socal"],
);
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* Weather, once a source is actually turned on.
*
* `api.weather.gov` is stood up as a stub here rather than called, for the
* obvious reason and for a less obvious one: the assertions that matter are
* about **which URL this server constructs**, and a real upstream would answer
* the right way for the wrong reason. The load-bearing one is that a caller
* asking for Berkeley causes a fetch of San Francisco's point and never of
* Berkeley's — the difference between a map and an open proxy pointed at a
* public-good API with this deployment's contact address on it.
*
* Global `fetch` is replaced for the file. `node --test` runs each test file in
* its own process, so nothing here leaks into another one, and `after` puts the
* real one back anyway.
*/
import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
const CONTACT = "ops@example.com";
const nwsEnv = { TERA_WEATHER_SOURCE: "nws", TERA_WEATHER_CONTACT: CONTACT };
interface Call {
url: string;
userAgent: string;
}
const realFetch = globalThis.fetch;
let calls: Call[] = [];
/** Flipped by a test that wants to watch the upstream go away mid-flight. */
let upstreamIsUp = true;
/**
* Flipped by a test that wants the upstream to stay *up* and answer 200 with
* something the parser cannot walk. `cloudLayers` becomes a number, which is
* the shape that used to throw straight through the cache.
*/
let upstreamIsGarbled = false;
/**
* The three hops NWS makes you take, and nothing else: an unrecognised URL is a
* 404, so a request this server should not be making shows up as a failure
* rather than as a plausible answer.
*/
function nws(url: string): unknown | undefined {
if (!upstreamIsUp) return undefined;
const point = /\/points\/(-?[\d.]+),(-?[\d.]+)$/.exec(url);
if (point !== null) {
return { properties: { observationStations: `https://api.weather.gov/zones/${point[1]}` } };
}
const zone = /\/zones\/(-?[\d.]+)$/.exec(url);
if (zone !== null) {
// One station id per latitude, so a body can be traced back to the point
// that was asked about.
return { features: [{ properties: { stationIdentifier: `K${zone[1]}` } }] };
}
const station = /\/stations\/K(-?[\d.]+)\/observations\/latest$/.exec(url);
if (station !== null) {
return {
properties: {
timestamp: "2026-08-05T09:00:00+00:00",
temperature: { value: Number(station[1]), unitCode: "wmoUnit:degC" },
windSpeed: { value: 9, unitCode: "wmoUnit:km_h-1" },
cloudLayers: upstreamIsGarbled ? 7 : [{ amount: "BKN" }],
presentWeather: [],
visibility: { value: 16_000, unitCode: "wmoUnit:m" },
},
};
}
return undefined;
}
globalThis.fetch = (async (input: unknown, init?: { headers?: Record<string, string> }) => {
const url = String(input);
// `http.ts` always passes a plain object, so this needs no `Headers` dance.
calls.push({ url, userAgent: init?.headers?.["user-agent"] ?? "" });
const body = nws(url);
if (body === undefined) return new Response("nope", { status: 503 });
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
upstreamIsUp = true;
upstreamIsGarbled = false;
});
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return buildApp(config);
}
function observations(): string[] {
return calls.filter((call) => call.url.includes("/observations/")).map((call) => call.url);
}
describe("a configured weather source", () => {
it("fetches the region centre and never the coordinate the caller sent", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
// Berkeley. Inside the Bay Area region, and not a point this deployment
// serves — so it selects San Francisco and San Francisco is what gets asked.
const res = await app.inject({
method: "GET",
url: "/api/v1/weather?lat=37.8715&lng=-122.2730",
});
assert.equal(res.statusCode, 200);
assert.ok(
calls.every((call) => !call.url.includes("37.8715")),
`the caller's coordinate reached the upstream: ${calls.map((c) => c.url).join(" ")}`,
);
assert.ok(calls.some((call) => call.url.endsWith("/points/37.7749,-122.4194")));
assert.deepEqual(res.json<WeatherBody>().location, { lat: 37.7749, lng: -122.4194 });
});
it("holds one observation per city rather than one per box", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
const sf = (
await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" })
).json<WeatherBody>();
const socal = (
await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" })
).json<WeatherBody>();
// The stub encodes the latitude in the temperature, so two different
// numbers here means two different stations were actually consulted.
assert.equal(sf.temperatureC, 37.7749);
assert.equal(socal.temperatureC, 33.82);
assert.equal(observations().length, 2);
assert.equal(sf.source, "nws");
assert.equal(sf.synthetic, false);
});
it("asks once per region per TTL however many callers turn up", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
const ask = () => app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
// Concurrent, then sequential: the first is the single-flight collapse, the
// second is the TTL. Both used to be one fetch each and only one of them was.
await Promise.all([ask(), ask(), ask(), ask()]);
await ask();
await ask();
assert.equal(observations().length, 1);
});
it("keeps the cities apart under load, not merely on the first request", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
const bodies = await Promise.all(
["sf", "socal", "sf", "socal", "sf"].map(async (city) =>
(await app.inject({ method: "GET", url: `/api/v1/weather?city=${city}` })).json<WeatherBody>(),
),
);
assert.deepEqual(
bodies.map((body) => body.temperatureC),
[37.7749, 33.82, 37.7749, 33.82, 37.7749],
);
assert.equal(observations().length, 2);
});
/**
* A source that is *up* and answering in a shape this build cannot read is a
* different failure from one that is down, and it used to be a much worse
* one: the parser threw, `upstream.ts` never reached its clock stamp, and the
* TTL — the only thing standing between a public-good API and one outbound
* request per inbound request — stopped existing. `current()` also stopped
* being the thing its own header calls it, which is a function that never
* throws.
*/
it("treats a body it cannot parse as a source that did not answer", async () => {
upstreamIsGarbled = true;
const app = appWith(nwsEnv);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
assert.equal(res.statusCode, 200);
// The clear day, which is what "nobody answered" has always meant here.
assert.equal(res.json<WeatherBody>().synthetic, true);
});
it("keeps the TTL when the body is garbled, not just when the socket dies", async () => {
upstreamIsGarbled = true;
const app = appWith(nwsEnv);
after(() => app.close());
for (let i = 0; i < 5; i++) {
await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
}
assert.equal(observations().length, 1);
});
it("identifies the operator to a source that requires it", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
await app.inject({ method: "GET", url: "/api/v1/weather" });
assert.ok(calls.length > 0);
for (const call of calls) assert.match(call.userAgent, new RegExp(CONTACT));
});
it("serves the last good observation when the upstream goes away", async () => {
// TTL 0 makes every request a refetch, which is what makes the failure
// reachable in a test without waiting ten minutes for one.
const app = appWith({ ...nwsEnv, TERA_WEATHER_TTL: "0" });
after(() => app.close());
const first = (
await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" })
).json<WeatherBody>();
assert.equal(first.temperatureC, 37.7749);
upstreamIsUp = false;
const second = await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
assert.equal(second.statusCode, 200);
const body = second.json<WeatherBody>();
// Not a 503, not a clear day: the observation from a minute ago, which is
// the only answer that keeps the sky looking like the sky.
assert.equal(body.temperatureC, 37.7749);
assert.equal(body.synthetic, false);
});
it("falls back to a clear day for a city that has never answered", async () => {
upstreamIsUp = false;
const app = appWith(nwsEnv);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" });
assert.equal(res.statusCode, 200);
const body = res.json<WeatherBody>();
assert.equal(body.synthetic, true);
assert.equal(body.condition, "clear");
assert.deepEqual(body.location, { lat: 33.82, lng: -118.05 });
});
it("retries a dead source on the TTL, not on every request", async () => {
upstreamIsUp = false;
const app = appWith(nwsEnv);
after(() => app.close());
for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/weather" });
// One attempt, one failure, one clock stamp. Somebody else's outage must not
// turn into this box's outbound flood.
assert.equal(calls.length, 1);
});
it("makes no outbound request at all until somebody asks", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
await app.inject({ method: "GET", url: "/api/v1/health" });
assert.deepEqual(calls, []);
});
it("never calls anybody when the source is off", async () => {
const app = appWith({});
after(() => app.close());
await app.inject({ method: "GET", url: "/api/v1/weather?city=sf" });
await app.inject({ method: "GET", url: "/api/v1/weather?city=socal" });
assert.deepEqual(calls, []);
});
it("refuses the request before it would have fetched anything", async () => {
const app = appWith(nwsEnv);
after(() => app.close());
const res = await app.inject({
method: "GET",
url: "/api/v1/weather?lat=51.5072&lng=-0.1276",
});
assert.equal(res.statusCode, 400);
// The point of the allowlist: a refused request costs the upstream nothing.
assert.deepEqual(calls, []);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* A per-key TTL cache in front of something that is allowed to fail.
*
* Weather and flights had the same fifteen lines each, and once both of them
* became *per region* they would have had the same fifteen lines with the same
* `Map` bolted on. Three rules, and they are the three the weather service
* already stated:
*
* 1. **Nothing is fetched until somebody asks.** A box nobody visits makes no
* outbound requests at all, which is what keeps a public-good API's fair-use
* policy satisfiable by a deployment that is mostly idle.
* 2. **A dead upstream serves the last good answer**, per key, and only reports
* nothing when it has never once answered for that key. Ten-minute-old
* weather beats no weather by a mile and beats a 503 by more.
* 3. **A failed fetch stamps the clock too**, and a fetch that *throws* is a
* fetch that failed. A source that is down — or that has started answering
* in a shape this build cannot read — is retried on the same cadence as one
* that is up, not on every request, which is the bug that turns somebody
* else's outage into your outbound flood. See `refresh`.
*
* Concurrent misses on one key collapse into one upstream request. Misses on
* *different* keys do not, deliberately: they are different places and the
* caller's rate budget is worked out per key in `flights/index.ts`.
*
* ### On the size of the map
*
* Keys are region ids, and the region set comes from the environment
* (`regions.ts`), so the number of entries is bounded by the operator's config
* and cannot be grown by anybody sending requests. That invariant lives in the
* routes, which resolve a query to a configured region *before* anything here is
* touched; this module trusts it and does not re-check it. Key this on anything
* a caller can choose and it becomes an unbounded map.
*/
export interface UpstreamLog {
warn(msg: string): void;
}
export interface Upstream<T> {
/**
* The freshest value for `key`, or `null` if nothing has ever answered for it.
*
* `fetchFresh` is passed per call rather than at construction so the caller
* can close over the region it just resolved instead of keeping a second
* lookup table. It must be the same function of the same key every time, which
* is trivially true for both callers here — the key *is* the region.
*/
get(key: string, fetchFresh: () => Promise<T | null>): Promise<T | null>;
}
interface Entry<T> {
value: T | null;
/** Epoch ms of the last completed attempt, successful or not. */
fetchedAt: number;
inFlight: Promise<void> | null;
}
export interface UpstreamOptions {
/** Prefix for the one warning line this ever logs, e.g. `weather:nws`. */
label: string;
ttlSeconds: number;
log: UpstreamLog;
}
export function createUpstream<T>(opts: UpstreamOptions): Upstream<T> {
const ttlMs = Math.max(0, opts.ttlSeconds) * 1000;
const entries = new Map<string, Entry<T>>();
/**
* One attempt, and it cannot fail in a way the caller has to know about.
*
* The `try` is rule 3, and the `finally` is the whole of it. `fetchFresh` is
* supposed to return `null` for every failure — `http.ts` does exactly that —
* but "supposed to" is not a guarantee, and one upstream that answers 200
* with a field of the wrong type is enough: `adsb.ts` iterating a non-array
* `ac`, or `nws.ts` iterating a `cloudLayers` that came back as a number,
* throws out of here. Before this, that throw skipped the clock stamp *and*
* propagated to the route, so the TTL — the only rate limit on outbound calls
* — collapsed to one upstream request per inbound request and the caller got
* a 500. Five requests to `/api/v1/flights` produced five fetches at
* adsb.lol, from the operator's address, and five 500s; that is the flood
* rule 3 exists to prevent, delivered by the failure mode it exists for.
*
* So a throw is made indistinguishable from the `null` it should have been:
* clock stamped, last good value kept, route answers 200 with whatever is in
* hand. The stack goes in the log line, because a source whose *shape*
* changed is a different problem from a source that is down and the operator
* needs to be able to tell them apart.
*/
async function refresh(key: string, entry: Entry<T>, fetchFresh: () => Promise<T | null>) {
let fresh: T | null = null;
let threw: unknown = null;
try {
fresh = await fetchFresh();
} catch (err) {
threw = err;
} finally {
entry.fetchedAt = Date.now();
}
if (threw === null && fresh !== null) {
entry.value = fresh;
return;
}
const why =
threw === null
? "did not answer"
: `answered with something this build cannot read (${threw instanceof Error ? threw.message : String(threw)})`;
opts.log.warn(
`${opts.label}: ${key} ${why}; serving ` +
`${entry.value === null ? "the fallback" : "the last good answer"}`,
);
}
return {
async get(key: string, fetchFresh: () => Promise<T | null>): Promise<T | null> {
let entry = entries.get(key);
if (entry === undefined) {
entry = { value: null, fetchedAt: 0, inFlight: null };
entries.set(key, entry);
}
if (Date.now() - entry.fetchedAt > ttlMs) {
const pending = entry;
pending.inFlight ??= refresh(key, pending, fetchFresh).finally(() => {
pending.inFlight = null;
});
await pending.inFlight;
}
return entry.value;
},
};
}
+33 -39
View File
@@ -1,26 +1,38 @@
/** /**
* Which source answers, how often it is asked, and what happens when it does not. * Which source answers, for which place, how often it is asked, and what happens
* when it does not.
* *
* Three rules, in order of how much trouble getting them wrong causes: * Four rules, in order of how much trouble getting them wrong causes:
* *
* 1. **This never throws.** `current()` always resolves to a `WeatherBody`. * 1. **This never throws.** `current()` always resolves to a `WeatherBody`.
* 2. **A dead upstream serves the last good observation**, and only falls back * 2. **A dead upstream serves the last good observation** for that place, and
* to the clear day if there has never been one. Ten-minute-old weather is * only falls back to the clear day if there has never been one. Ten-minute-old
* better than no weather and much better than a 503. * weather is better than no weather and much better than a 503.
* 3. **Nothing is fetched until somebody asks.** A box nobody visits makes no * 3. **Nothing is fetched until somebody asks.** A box nobody visits makes no
* outbound requests, which matters when the source is a public good with a * outbound requests, which matters when the source is a public good with a
* rate limit and a fair-use policy. * rate limit and a fair-use policy.
* 4. **One cache entry per region, and the regions come from the environment.**
* San Francisco's fog and Los Angeles's sun are two different observations
* and the old single-origin cache could only hold one of them. The keys are
* region ids and nothing a caller sends becomes one — `routes/weather.ts`
* resolves the query to a configured region first, which is what bounds both
* this map and the number of stations `nws.ts` will ever look up.
*
* Rules 13 live in `upstream.ts` now, because flights wanted exactly the same
* three.
*/ */
import type { Config } from "../config.ts"; import type { Config } from "../config.ts";
import type { Region } from "../regions.ts";
import type { WeatherBody } from "../../../src/server/wire.ts"; import type { WeatherBody } from "../../../src/server/wire.ts";
import { createUpstream } from "../upstream.ts";
import { fetchMetno } from "./metno.ts"; import { fetchMetno } from "./metno.ts";
import { fetchNws } from "./nws.ts"; import { fetchNws } from "./nws.ts";
import { fetchOpenMeteo } from "./openmeteo.ts"; import { fetchOpenMeteo } from "./openmeteo.ts";
import { clearDay } from "./synthetic.ts"; import { clearDay } from "./synthetic.ts";
export interface WeatherService { export interface WeatherService {
current(): Promise<WeatherBody>; current(region: Region): Promise<WeatherBody>;
} }
export interface WeatherLog { export interface WeatherLog {
@@ -28,46 +40,28 @@ export interface WeatherLog {
} }
export function createWeatherService(config: Config, log: WeatherLog): WeatherService { export function createWeatherService(config: Config, log: WeatherLog): WeatherService {
const { lat, lng } = config.origin;
const { source, contact, ttlSeconds } = config.weather; const { source, contact, ttlSeconds } = config.weather;
const upstream = createUpstream<WeatherBody>({ label: `weather:${source}`, ttlSeconds, log });
let cached: WeatherBody | null = null; function fetchFor(region: Region): Promise<WeatherBody | null> {
let fetchedAt = 0; // The coordinate that goes upstream is the *region centre*, never the one
let inFlight: Promise<void> | null = null; // the caller sent. See the top of `regions.ts` for why that distinction is
// the whole abuse story on this endpoint.
async function refresh(): Promise<void> { const { lat, lng } = region;
const fresh = return source === "nws"
source === "nws" ? fetchNws(lat, lng, contact)
? await fetchNws(lat, lng, contact)
: source === "metno" : source === "metno"
? await fetchMetno(lat, lng, contact) ? fetchMetno(lat, lng, contact)
: source === "openmeteo" : source === "openmeteo"
? await fetchOpenMeteo(lat, lng) ? fetchOpenMeteo(lat, lng)
: null; : Promise.resolve(null);
// Stamp the clock either way. A source that is down should be retried on the
// same cadence as one that is up, not hammered once per request.
fetchedAt = Date.now();
if (fresh !== null) {
cached = fresh;
return;
}
log.warn(`weather: ${source} did not answer; serving ${cached === null ? "a synthetic clear day" : "the last observation"}`);
} }
return { return {
async current(): Promise<WeatherBody> { async current(region: Region): Promise<WeatherBody> {
if (source === "none") return clearDay(lat, lng); if (source === "none") return clearDay(region.lat, region.lng);
const body = await upstream.get(region.id, () => fetchFor(region));
const stale = Date.now() - fetchedAt > ttlSeconds * 1000; return body ?? clearDay(region.lat, region.lng);
if (stale) {
// Collapse concurrent misses into one upstream request.
inFlight ??= refresh().finally(() => {
inFlight = null;
});
await inFlight;
}
return cached ?? clearDay(lat, lng);
}, },
}; };
} }
+7
View File
@@ -10,6 +10,13 @@
* Getting an observation takes three hops — point to station list, station list * Getting an observation takes three hops — point to station list, station list
* to nearest station, station to latest observation — so the first two are * to nearest station, station to latest observation — so the first two are
* resolved once per process and kept. Stations do not move. * resolved once per process and kept. Stations do not move.
*
* That station cache is keyed on the coordinate, which means its size is exactly
* the number of places this box will answer for. It stays bounded because the
* only coordinates that reach here are region centres from `regions.ts`: a
* caller cannot name a point, so a caller cannot grow this map, and the three
* hops are paid once per region for the life of the process rather than once per
* curious request.
*/ */
import { getJson, userAgent } from "../http.ts"; import { getJson, userAgent } from "../http.ts";
+72 -5
View File
@@ -86,12 +86,45 @@ export interface Capabilities {
debug: boolean; debug: boolean;
} }
/**
* Which of the three feeds this deployment has actually wired.
*
* A capability says what a *visitor* may have; this says what the *server* has,
* and the app needs both before it opens a socket. `can.liveData` is true for
* every member of every deployment, including the overwhelming majority that
* have `weather: "none"` — so gating on the capability alone starts a
* ten-minute weather poll against a box that will answer 404 to all of it,
* forever, on every tab that is open.
*
* Each field is `true` when `/health` named a source other than `"none"`, which
* is deliberately coarser than the string. The app does not care whether the
* weather comes from NWS or met.no; it cares whether asking is pointless.
* `flights: "sim"` counts as wired, because the server's synchronised plan is
* worth fetching even though it is not observed — `TrafficSource.live()` is the
* thing that knows the difference, and it says `false` for it.
*/
export interface Feeds {
weather: boolean;
flights: boolean;
markers: boolean;
}
export interface Access { export interface Access {
tier: Tier; tier: Tier;
subject: string | null; subject: string | null;
/** Where to send someone who is not signed in. `null` means this deployment has no door. */ /** Where to send someone who is not signed in. `null` means this deployment has no door. */
signInUrl: string | null; signInUrl: string | null;
can: Capabilities; can: Capabilities;
/**
* What `/health` said is wired, or `null` when nothing answered — which is
* the zero-config case, and means every feed is the bundled sample.
*
* It rides along here rather than being fetched again by whoever wants it
* because this module has already paid for the round trip: `/health` is the
* first thing boot asks for, and a second identical GET a moment later to
* read a different field of the same body is a request nobody needs to make.
*/
feeds: Feeds | null;
} }
/** /**
@@ -147,6 +180,7 @@ export function capabilitiesFor(tier: Tier): Capabilities {
export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> { export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> {
const health = await getJson<{ const health = await getJson<{
auth?: { mode?: unknown; entryUrl?: unknown }; auth?: { mode?: unknown; entryUrl?: unknown };
sources?: unknown;
}>(fetcher, "/health"); }>(fetcher, "/health");
// Something is mounted at `/api/v1` and it is unwell. That is not the same // Something is mounted at `/api/v1` and it is unwell. That is not the same
@@ -165,10 +199,11 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
const body = health.body; const body = health.body;
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none"; const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
const entryUrl = entryHref(body.auth?.entryUrl); const entryUrl = entryHref(body.auth?.entryUrl);
const feeds = feedsFrom(body.sources);
// A box with auth switched off is a self-host that chose to stay open. Same // A box with auth switched off is a self-host that chose to stay open. Same
// deal as no API at all, and for the same reason it is `member` and not `god`. // deal as no API at all, and for the same reason it is `member` and not `god`.
if (mode === "none") return access("member", null, null); if (mode === "none") return access("member", null, null, feeds);
const fetched = await getJson<{ const fetched = await getJson<{
authenticated?: unknown; authenticated?: unknown;
@@ -211,12 +246,32 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
*/ */
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null); const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
if (!authenticated) return access("anon", null, signInUrl); if (!authenticated) return access("anon", null, signInUrl, feeds);
return access(admin ? "god" : "member", subject, signInUrl); return access(admin ? "god" : "member", subject, signInUrl, feeds);
} }
function access(tier: Tier, subject: string | null, signInUrl: string | null): Access { function access(
return { tier, subject, signInUrl, can: capabilitiesFor(tier) }; tier: Tier,
subject: string | null,
signInUrl: string | null,
feeds: Feeds | null = null,
): Access {
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds };
}
/**
* `/health`'s `sources` block, read as three yes/no answers.
*
* Defensively, like `admin` above and for the same reason: this field is newer
* than some servers this client will meet, and a missing one has to fall the
* safe way. Here "safe" is `false` — no feed, no request — because the bundled
* sample set is a working map and a poll against a server that never heard of
* the route is not.
*/
function feedsFrom(raw: unknown): Feeds {
const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none";
return { weather: wired("weather"), flights: wired("flights"), markers: wired("markers") };
} }
/** /**
@@ -291,6 +346,18 @@ async function getJson<T>(fetcher: typeof fetch, path: string): Promise<Fetched<
*/ */
function entryHref(raw: unknown): string | null { function entryHref(raw: unknown): string | null {
if (typeof raw !== "string" || raw === "") return null; if (typeof raw !== "string" || raw === "") return null;
/**
* Before `new URL`, because `new URL` is what hides this one.
*
* A protocol-relative `//evil.example/login` inherits the page's scheme, so
* `url.protocol` comes back `https:` and the check below waves it through —
* the comment above listed it among the rejected set and it was not among the
* rejected set. It is not the `javascript:` case and it is not script
* execution; it is a value an operator pasted, or an API answered with, being
* turned into a link off this origin that says "Sign in" on it. A host that
* wants to be honoured can write its scheme.
*/
if (/^\s*\/\//.test(raw)) return null;
try { try {
const url = new URL(raw, window.location.origin); const url = new URL(raw, window.location.origin);
if (url.protocol !== "https:" && url.protocol !== "http:") return null; if (url.protocol !== "https:" && url.protocol !== "http:") return null;
+16 -3
View File
@@ -35,16 +35,29 @@ it does not have to happen.
```ts ```ts
const tera = createTeraClient(); // same-origin /api/v1 const tera = createTeraClient(); // same-origin /api/v1
const markers = await tera.markers(); const markers = await tera.markers();
const scene = createScene(canvas, { const scene = await createScene(stage, {
city: SAN_FRANCISCO, city: SAN_FRANCISCO,
markerPalette: markers.palette, markerPalette: markers.palette,
flights: tera.flights(), flights: tera.flights(regionOf(SAN_FRANCISCO), sampleRoutesFor("sf")),
}); });
scene.setMarkers(markers.value); scene.setMarkers(markers.value);
if (!markers.live) showSampleDataNotice(); if (!markers.live) showSampleDataNotice();
``` ```
Note the ordering: `markerPalette` is fixed when the scene is built, so the Three things about that call are load-bearing.
`createScene` is **async** and takes a `Stage` rather than a canvas: the
heightfield is built in a Worker, and the renderer outlives any one city, so the
stage is created once for the page and handed to each scene in turn.
`flights` takes a **region**, not a client-wide origin. The Bay Area and SoCal
are six hundred kilometres apart and a single configured origin served one of
them and lied to the other. `regionOf(city)` derives it from the city's own
bounds, so a city pack added later needs no configuration to get its own sky.
The second argument is the simulated traffic to fly when the API has none, which
is what keeps the zero-config case from showing an empty sky.
And note the ordering: `markerPalette` is fixed when the scene is built, so the
markers have to be awaited first. `markers.palette` is the sample palette when 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 the feed is the sample set and the palette you passed in `TeraApiOptions` when it
is real — the sample keys are not your keys. is real — the sample keys are not your keys.
+718 -68
View File
@@ -11,19 +11,37 @@
* *
* **Every call degrades instead of failing.** No server, a 404, a static host * **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 * answering `/api/v1/markers` with its own index.html, a network that has gone
* away mid-session: all of it lands on the sample data in `sample.ts` and the * away mid-session: all of it lands on the sample data in `sample.ts` and on a
* synthetic clear day below, and the map keeps rendering. That is not defensive * sky nobody claims to have observed, and the map keeps rendering. That is not
* habit, it is the acceptance test the whole repo is held to — a stranger clones * defensive habit, it is the acceptance test the whole repo is held to — a
* this, runs one command, and gets a city, with no account, no key and no * stranger clones this, runs one command, and gets a city, with no account, no
* network (CONTRACT.md §0). A `npm run build` deployed to any static host is a * 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. * 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 * The wire types live in `src/server/wire.ts` and are types only, so importing
* them costs the bundle nothing. * them costs the bundle nothing.
*/ */
import type { WeatherObservation } from "../engine/atmosphere.ts"; import type { WeatherObservation } from "../engine/atmosphere.ts";
import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts"; import {
distanceNm,
inRegion,
sampleRoute,
SimulatedFlights,
syntheticRoutes,
type Place,
type SimRoute,
type SkyRegion,
} from "../engine/flights.ts";
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts"; import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
import { seededRandom } from "../engine/world.ts"; import { seededRandom } from "../engine/world.ts";
import type { import type {
@@ -34,7 +52,7 @@ import type {
OfficeDoc, OfficeDoc,
WeatherBody, WeatherBody,
} from "../server/wire.ts"; } from "../server/wire.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts"; import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */ /** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const DEFAULT_BASE = "/api/v1"; const DEFAULT_BASE = "/api/v1";
@@ -45,6 +63,19 @@ const DEFAULT_TIMEOUT_MS = 4000;
/** How long to wait before trying the flights endpoint again after it fails. */ /** How long to wait before trying the flights endpoint again after it fails. */
const RETRY_SECONDS = 30; 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 { export interface TeraApiOptions {
/** /**
* Base URL, with no trailing slash. Absolute is allowed and is what a * Base URL, with no trailing slash. Absolute is allowed and is what a
@@ -97,20 +128,82 @@ export interface MarkerFeed extends Feed<Marker[]> {
attribution: string[]; attribution: string[];
} }
export interface WeatherFeed extends Feed<WeatherObservation> { /**
* The sky, or an admission that nobody knows what the sky is doing.
*
* `value` is nullable and that null is load-bearing rather than lazy. It is
* exactly `Environment.weather` in `atmosphere.ts`, where `null` means "nobody
* was asked" and lets the local climatology run, and a `WeatherObservation`
* means somebody looked — which `apply` then treats as authority over the
* model. So the type matches the argument it is destined for, the caller can
* hand `feed.value` straight to `observe()`, and there is no shape in which a
* failed fetch can be mistaken for a report of a clear sky. See
* `noObservation` for what that mistake actually did to the fog.
*/
export interface WeatherFeed extends Feed<WeatherObservation | null> {
/**
* ISO-8601 observation time, or `null` when nobody observed anything.
*
* The *observation* time and not the fetch time, which is the field's whole
* value: the server serves from a ten-minute cache, so a body that arrived a
* second ago can already describe a sky from ten minutes ago, and the only
* way to know how old the weather is is to be told.
*/
observedAt: string | null;
attribution: string[]; attribution: string[];
} }
/**
* A live weather feed for one place, polled until somebody stops it.
*
* A watch and not a promise because weather has no natural moment: the map is
* open for an hour, the marine layer arrives at some point during it, and a
* value fetched once at boot is a photograph of a sky that has since changed.
*
* `stop()` is not optional housekeeping. Switching city while a poll is in
* flight is the ordinary case, not the rare one — the request takes a second
* and the button takes a moment — and an answer for the old city landing in the
* new city's rig is San Francisco's fog over Long Beach. So a stopped watch
* aborts what it has in the air and refuses to publish anything that arrives
* anyway.
*/
export interface WeatherWatch {
/** The latest feed. Nobody-was-asked, and not live, until an answer lands. */
current(): WeatherFeed;
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
refresh(): void;
/** Stop polling, abort anything in flight, and drop any late answer. */
stop(): void;
}
export interface TeraClient { export interface TeraClient {
/** What the deployment turned out to be, or `null` if there is no server. */ /** What the deployment turned out to be, or `null` if there is no server. */
health(): Promise<HealthBody | null>; health(): Promise<HealthBody | null>;
markers(): Promise<MarkerFeed>; markers(): Promise<MarkerFeed>;
weather(): Promise<WeatherFeed>;
/** /**
* The traffic source, built once. It fetches on its own schedule and never * The sky over one place, once.
* blocks the render loop; see `HttpFlights`. *
* `at` is required. There is no sensible default for it — see the note at the
* top of this file — and a default would have been the bug.
*/ */
flights(): FlightSource; weather(at: Place, options?: { signal?: AbortSignal }): Promise<WeatherFeed>;
/**
* The sky over one place, kept up to date. `onFeed` fires once per settled
* poll, including the ones that change nothing.
*/
watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch;
/**
* The traffic source for one region. It fetches on its own schedule and never
* blocks the render loop; see `HttpFlights`.
*
* `fallbackRoutes` is what the simulator flies while the network has not
* answered, and defaults to something generated inside the region rather than
* to this repo's sample set — the sample set is over San Francisco, and a
* default that is only correct for one city is the failure this signature was
* changed to prevent. Callers with hand-authored corridors for the city
* should pass them; `sampleRoutesFor` in `sample.ts` has them.
*/
flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource;
/** /**
* One office pack. `null` for anything the server will not serve — including * 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 * a private one, which answers 404 rather than 403 so the endpoint cannot be
@@ -123,6 +216,23 @@ export interface TeraClient {
office(id: string): Promise<OfficeDoc | null>; office(id: string): Promise<OfficeDoc | null>;
} }
/**
* One GET's worth of options: what to put in the query string, and a way for
* the caller to give up on it.
*
* The signal is the caller's, and is in addition to this module's own timeout
* rather than instead of it. They cancel different things: the timeout is about
* a server that is slow, and the signal is about an answer that has stopped
* being wanted — a city switched, a page unloading — which can happen well
* inside a healthy response time.
*/
interface GetOptions {
query?: Record<string, string | number>;
signal?: AbortSignal;
}
type Get = <T>(path: string, options?: GetOptions) => Promise<T | null>;
export function createTeraClient(options: TeraApiOptions = {}): TeraClient { export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, ""); const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, "");
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -131,17 +241,25 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
/** /**
* One GET, and `null` for every way it can go wrong. * One GET, and `null` for every way it can go wrong.
* *
* Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static * Deliberately undiscriminating. A 404, a timeout, a CORS refusal, an abort, a
* host serving `index.html` with a 200 and an HTML content type — the caller's * static host serving `index.html` with a 200 and an HTML content type — the
* response to all of them is the same, and a taxonomy of failures nobody * caller's response to all of them is the same, and a taxonomy of failures
* branches on is a taxonomy nobody maintains. * nobody branches on is a taxonomy nobody maintains.
*
* Note that an abort therefore looks exactly like a failure. Everything that
* aborts on purpose here checks its own cancelled flag before doing anything
* with the `null`, because treating "you asked me to stop" as "the server is
* down" would have a city switch trip the back-off ladder.
*/ */
async function get<T>(path: string): Promise<T | null> { const get: Get = async <T,>(path: string, opts: GetOptions = {}): Promise<T | null> => {
if (!doFetch) return null; if (!doFetch) return null;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const abort = () => controller.abort();
opts.signal?.addEventListener("abort", abort);
if (opts.signal?.aborted) controller.abort();
const timer = setTimeout(abort, timeoutMs);
try { try {
const res = await doFetch(`${base}${path}`, { const res = await doFetch(`${base}${path}${queryString(opts.query)}`, {
signal: controller.signal, signal: controller.signal,
headers: { accept: "application/json" }, headers: { accept: "application/json" },
}); });
@@ -156,10 +274,9 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
return null; return null;
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
opts.signal?.removeEventListener("abort", abort);
} }
} };
let flightSource: FlightSource | null = null;
return { return {
health: () => get<HealthBody>("/health"), health: () => get<HealthBody>("/health"),
@@ -189,43 +306,318 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
}; };
}, },
async weather(): Promise<WeatherFeed> { async weather(at: Place, opts: { signal?: AbortSignal } = {}): Promise<WeatherFeed> {
const body = await get<WeatherBody>("/weather"); const body = await get<WeatherBody>("/weather", {
if (!body) return { value: CLEAR_DAY, live: false, attribution: [] }; query: whereQuery(at),
// `WeatherBody` is structurally a `WeatherObservation` plus fields no ...(opts.signal ? { signal: opts.signal } : {}),
// renderer reads, which `atmosphere.ts` says in as many words. The extra });
// fields ride along harmlessly and the engine never sees them. return weatherFeed(at, body);
return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] };
}, },
flights(): FlightSource { watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch {
flightSource ??= new HttpFlights(get, SAMPLE_ROUTES); return watchWeather(get, at, onFeed);
return flightSource; },
flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource {
return new HttpFlights(get, region, fallbackRoutes ?? syntheticRoutes(region));
}, },
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`), office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
}; };
} }
// ---- Weather --------------------------------------------------------------
/** /**
* The clear day a zero-config box serves, restated in the browser. * How often a watch asks, when the last answer was a good one.
* *
* The server does this too — a weather source configured without what it needs * Ten minutes, which is the server's own `TERA_WEATHER_TTL` default and not a
* is demoted rather than fatal, and it answers `synthetic: true` forever * coincidence: asking faster spends a request to be handed the same cached body
* (CONTRACT.md §5.1). This is the same answer for the case where there is no * back. The upstreams behind that cache agree about the order of magnitude —
* server at all. Note what it does *not* do: `visibilityKm` stays null, which * NWS publishes observations hourly, MET Norway's terms ask callers to respect
* `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San * the `Expires` header rather than poll on their own clock, and Open-Meteo's
* Francisco's marine layer still runs off its own climatology instead of being * current block moves quarter-hourly. Nothing that arrives faster than ten
* overruled by a fact nobody observed. * minutes is new information.
*
* The other end of the argument is what the map does with it. Cloud cover moves
* a directional light's intensity and a fog distance, both of which are ramped
* over seconds by `atmosphere.ts` anyway, so a late arrival looks like weather
* changing and never like a jump. This is a map, not a dashboard: the marine
* layer arriving three minutes after it really did is not an error anybody can
* detect, and a poll a minute for eight hours is 480 requests to find that out.
*/ */
const CLEAR_DAY: WeatherObservation = { const WEATHER_INTERVAL_MS = 10 * 60_000;
cloudCover: 0.1,
precipitation: 0, /**
visibilityKm: null, * The ceiling on the back-off ladder a failing watch climbs.
windKph: null, *
windDirDeg: null, * Backing off is the *normal* path here rather than an outage measure. The
condition: "clear", * 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 { function sampleMarkerFeed(): MarkerFeed {
return { return {
@@ -238,8 +630,60 @@ function sampleMarkerFeed(): MarkerFeed {
}; };
} }
// ---- Asking about a place -------------------------------------------------
/**
* The location half of a query, rounded to about a kilometre.
*
* Rounded for the shared cache, which is the whole reason the precision is
* thrown away. `/weather` and `/flights` are served with a public cache header
* and the query string is part of the cache key, so two viewers of the same
* city have to produce byte-identical URLs or the cache is a per-viewer cache
* and the upstream gets hit once per person. A city centre is a constant in
* this build and would round identically anyway; a caller that ever passes a
* camera position instead gets the same protection for free, along with not
* having put anybody's exact position in an access log.
*/
function whereQuery(at: Place): Record<string, string> {
return { lat: at.lat.toFixed(2), lng: at.lng.toFixed(2) };
}
function queryString(query: Record<string, string | number> | undefined): string {
if (!query) return "";
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) params.set(key, String(value));
const encoded = params.toString();
return encoded === "" ? "" : `?${encoded}`;
}
// ---- Traffic -------------------------------------------------------------- // ---- Traffic --------------------------------------------------------------
/**
* A `FlightSource` that also knows whether what it is handing over is real.
*
* The extra method is here rather than on `FlightSource` in `engine/types.ts`
* because the engine has no business with provenance: it draws darts at
* coordinates, and whether the coordinates were observed is a question about
* the deployment. Only the interface layer asks it, so only this layer declares
* it.
*/
export interface TrafficSource extends FlightSource {
/** True while the aircraft `poll()` returns are observed positions for this region. */
live(): boolean;
/**
* Credit lines for whatever is currently being drawn, and empty when nothing
* on screen came from anybody else.
*
* Here for the same reason `live()` is: the engine draws darts and has no
* business with provenance, but a community feed that asks to be named has
* asked the *deployment*, and this is the layer that knows a deployment
* exists. `describeLiveness` says what is live; this says who to thank for it.
*/
attribution(): string[];
/** Stop fetching and abort anything in flight. Idempotent. */
dispose(): void;
}
/** /**
* Traffic over HTTP, in whichever of the two shapes the server chose. * Traffic over HTTP, in whichever of the two shapes the server chose.
* *
@@ -257,10 +701,20 @@ function sampleMarkerFeed(): MarkerFeed {
* refetched. * refetched.
* *
* Until the first response lands, and after any failure, this is the simulator * Until the first response lands, and after any failure, this is the simulator
* over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and * over whatever routes the caller handed in. An empty sky is a worse answer than
* the invented one is labelled as such in `sample.ts`. * an invented one, and the invented one is labelled as such in `sample.ts`.
*
* **Everything that arrives is checked against the region before it is drawn.**
* The region goes out on the query and is checked again on the way back, which
* is not belt and braces — a server built before that parameter existed answers
* every caller from its single configured origin, and it answers 200. A plan
* whose routes are all somewhere else, or a snapshot with aircraft in it but
* none of them here, is not this city's sky and is refused in favour of the
* simulator. The failure this prevents is not subtle: San Francisco's traffic
* over the SoCal board projects clean off the world and renders as nothing at
* all, so the map looks broken rather than wrong.
*/ */
class HttpFlights implements FlightSource { class HttpFlights implements TrafficSource {
/** /**
* One second, which is the *evaluation* cadence and not the request cadence. * 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; * A plan is arithmetic and wants to be evaluated every frame or close to it;
@@ -269,14 +723,18 @@ class HttpFlights implements FlightSource {
readonly interval = 1; readonly interval = 1;
private readonly fallback: SimulatedFlights; private readonly fallback: SimulatedFlights;
private mode: "fallback" | "plan" | "live" = "fallback";
private plan: FlightsPlanBody | null = null; private plan: FlightsPlanBody | null = null;
private planPhase: number[] = []; private planPhase: number[] = [];
private live: Aircraft[] | null = null; private aircraft: Aircraft[] = [];
private credits: string[] = [];
private nextFetchAt = 0; private nextFetchAt = 0;
private fetching = false; private inFlight: AbortController | null = null;
private stopped = false;
constructor( constructor(
private readonly get: <T>(path: string) => Promise<T | null>, private readonly get: Get,
private readonly region: SkyRegion,
fallbackRoutes: SimRoute[], fallbackRoutes: SimRoute[],
) { ) {
this.fallback = new SimulatedFlights(fallbackRoutes); this.fallback = new SimulatedFlights(fallbackRoutes);
@@ -284,17 +742,52 @@ class HttpFlights implements FlightSource {
poll(): Aircraft[] { poll(): Aircraft[] {
this.refreshIfStale(); this.refreshIfStale();
if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now()); const { mode, plan, planPhase } = this;
if (this.live) return this.live; if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now());
if (this.mode === "live") return this.aircraft;
return this.fallback.poll(); return this.fallback.poll();
} }
/**
* The server's own simulated plan is not live traffic and does not say it is.
* It is a better simulation than the local one — every viewer agrees about
* where the aircraft are — but nobody observed any of it, and `sources.flights`
* on `/health` calls it `sim` for the same reason.
*/
live(): boolean {
return this.mode === "live";
}
/**
* Only while the body they came with is what is on screen. Falling back to
* the simulator drops them, because the simulator's aircraft are this repo's
* invention and crediting adsb.lol for them would be worse than crediting
* nobody.
*/
attribution(): string[] {
return this.mode === "fallback" ? [] : this.credits;
}
dispose(): void {
this.stopped = true;
this.inFlight?.abort();
this.inFlight = null;
}
private refreshIfStale(): void { private refreshIfStale(): void {
const now = Date.now(); const now = Date.now();
if (this.fetching || now < this.nextFetchAt) return; if (this.stopped || this.inFlight || now < this.nextFetchAt) return;
this.fetching = true; const controller = new AbortController();
void this.get<FlightsBody>("/flights") this.inFlight = controller;
void this.get<FlightsBody>("/flights", {
query: { ...whereQuery(this.region.center), radiusNm: Math.round(this.region.radiusNm) },
signal: controller.signal,
})
.then((body) => { .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) { if (!body) {
// Hold whatever was already in hand rather than reverting to the // Hold whatever was already in hand rather than reverting to the
// simulator: a deployment that has been showing real traffic for an // simulator: a deployment that has been showing real traffic for an
@@ -303,21 +796,128 @@ class HttpFlights implements FlightSource {
this.nextFetchAt = now + RETRY_SECONDS * 1000; this.nextFetchAt = now + RETRY_SECONDS * 1000;
return; return;
} }
if (body.mode === "plan") { this.nextFetchAt = now + this.adopt(body) * 1000;
this.plan = body; })
this.planPhase = phasesFor(body); /**
this.live = null; * The clock gets set whatever happens, and this is the branch that says
} else { * so for the case nobody plans for.
this.live = body.aircraft; *
this.plan = null; * `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
this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000; * 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(() => { .finally(() => {
this.fetching = false; if (this.inFlight === controller) this.inFlight = null;
}); });
} }
/**
* Take the body if it is about this region, and say how long to wait next.
*
* **Everything read off the body is checked first**, which `markers()` does a
* few hundred lines up and this did not. `Math.max(1, body.ttlSeconds)` on an
* absent `ttlSeconds` is `NaN`, `nextFetchAt` becomes `NaN`, and
* `now < this.nextFetchAt` is false forever — the poll interval quietly
* becomes the frame rate. `body.aircraft.filter(...)` on an absent array
* throws, which took the same route by a different door. Both were reachable
* from a 200 with valid JSON in it, which is what a server one version behind
* this one sends.
*
* A body that fails these is not this region's traffic and is treated as the
* failure it is: the simulator, and ask again in `RETRY_SECONDS`.
*/
private adopt(body: FlightsBody): number {
const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS;
this.credits = [];
if (body.mode === "plan") {
if (!Array.isArray(body.routes)) {
this.mode = "fallback";
this.plan = null;
return RETRY_SECONDS;
} }
// Phases are drawn over the *whole* plan and then filtered alongside the
// routes, never over the surviving subset. The seed is what makes two
// browsers agree about where the aircraft are, and it only does that if a
// given route draws the same number wherever it is looked at — filter
// first and a viewer of a two-city plan disagrees with a viewer of the
// one-city plan the same server would serve tomorrow.
const phases = phasesFor(body);
const routes: FlightsPlanBody["routes"] = [];
const planPhase: number[] = [];
body.routes.forEach((route, i) => {
// A leg counts as ours if either end is anywhere near the board — an
// arrival begins a long way outside it, which is most of the point of
// drawing traffic at all.
const mine =
inRegion(this.region, route.from[0], route.from[1], PLAN_SLACK_NM) ||
inRegion(this.region, route.to[0], route.to[1], PLAN_SLACK_NM);
if (!mine) return;
routes.push(route);
planPhase.push(phases[i] ?? 0);
});
if (routes.length === 0) {
this.mode = "fallback";
this.plan = null;
return ELSEWHERE_SECONDS;
}
// Only the legs that are here, so a server serving one plan for several
// metros does not put the other cities' aircraft off the edge of this one.
this.plan = { ...body, routes };
this.planPhase = planPhase;
this.mode = "plan";
return ttl;
}
if (!Array.isArray(body.aircraft)) {
this.mode = "fallback";
this.plan = null;
return RETRY_SECONDS;
}
const here = body.aircraft.filter((a) => inRegion(this.region, a.lat, a.lng, LIVE_SLACK_NM));
// An empty feed and a feed about somewhere else look the same after
// filtering and are not the same thing. Three in the morning over a small
// city really is an empty sky and should be drawn as one; a feed with fifty
// aircraft in it and not one of them within a hundred miles of the board is
// a server pointed at another city, and there the simulator is the honest
// picture.
if (here.length === 0 && body.aircraft.length > 0) {
this.mode = "fallback";
return ELSEWHERE_SECONDS;
}
this.aircraft = here;
this.plan = null;
this.mode = "live";
// Only the live body carries credits — `wire.ts` puts `attribution` on
// `FlightsLiveBody` and not on the plan, because the plan is this project's
// own arithmetic and there is nobody to thank for it. Filtered rather than
// trusted for the same reason as everything else in this method.
this.credits = Array.isArray(body.attribution)
? body.attribution.filter((line): line is string => typeof line === "string")
: [];
return ttl;
}
}
/**
* Slack on the region tests, in nautical miles.
*
* Generous on the plan, because a plan is checked once and rejecting it wrongly
* costs the deployment its whole shared sky for fifteen minutes. Tighter on
* live positions, where the test also does duty as the filter that keeps
* aircraft from being drawn off the edge of the board, and where a wrong answer
* costs one dart for one poll.
*/
const PLAN_SLACK_NM = 120;
const LIVE_SLACK_NM = 30;
/** /**
* The per-route phase offsets, from the seed the server sent. * The per-route phase offsets, from the seed the server sent.
@@ -337,3 +937,53 @@ function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Ai
// there so the server can build one without importing three.js. // 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))); return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0)));
} }
// ---- Saying which -----------------------------------------------------------
/**
* Which of the three feeds on screen are real, right now.
*
* Three booleans rather than one because they are genuinely independent: the
* markers come from a file the operator wrote, the weather from a government
* API, the traffic from a receiver on somebody's roof, and every combination of
* the three is a deployment that exists. A single flag has to pick one of them
* to be about and then lie about the other two.
*/
export interface Liveness {
/** Markers came from the API rather than from `sample.ts`. */
markers: boolean;
/** The sky is a real observation, of this city, recent enough to mean it. */
weather: boolean;
/** The aircraft are observed positions, in this city's region. */
flights: boolean;
}
/**
* The corner label, derived from what is actually live.
*
* This used to be one boolean and the boolean was the markers feed, so a
* deployment with a real ADS-B receiver and a real weather station but no
* marker file said nothing at all, and a deployment with a marker file and
* neither of the other two claimed the lot. Both are wrong the same way. The
* label sits in the corner of the whole map, so it is read as a claim about the
* whole map, and the only claim that can be made about the whole map is one
* that is true of all of it.
*
* Hence three cases. Nothing live is **silence**, which is `renderLegend`'s
* existing rule and the right one: a caption that is on screen always is
* furniture nobody reads, and the fabricated-data disclosure has a better home
* on the boot card and in the `?` card, where it is read once and reachable
* forever. Some of it live **names the parts**, because "live data" over
* invented companies is exactly the lie the `live` flag was introduced to
* prevent, and "live weather" over invented companies is not. All three live is
* the only case that earns the unqualified claim.
*/
export function describeLiveness(live: Liveness): string {
const parts: string[] = [];
if (live.weather) parts.push("weather");
if (live.flights) parts.push("traffic");
if (live.markers) parts.push("markers");
if (parts.length === 0) return "";
if (parts.length === 3) return "live data";
return `live ${parts.join(" + ")}`;
}
+52 -3
View File
@@ -27,8 +27,8 @@
* without data that exercises it. * without data that exercises it.
*/ */
import type { SimRoute } from "../engine/flights.ts"; import { regionOf, syntheticRoutes, type SimRoute } from "../engine/flights.ts";
import type { Marker, MarkerPalette } from "../engine/types.ts"; import type { City, Marker, MarkerPalette } from "../engine/types.ts";
/** /**
* A small pipeline, as colours. * A small pipeline, as colours.
@@ -287,7 +287,8 @@ export const SAMPLE_MARKERS: Marker[] = [
]; ];
/** /**
* Sample traffic, for when the API is not there to send a flight plan. * Sample traffic over the Bay Area, for when the API is not there to send a
* flight plan.
* *
* The corridors are roughly the real ones — arrivals down the peninsula from * The corridors are roughly the real ones — arrivals down the peninsula from
* the north, departures turning out over the Pacific, a slow light aircraft * the north, departures turning out over the Pacific, a slow light aircraft
@@ -296,6 +297,11 @@ export const SAMPLE_MARKERS: Marker[] = [
* these prefixes, which keeps a demo from looking like a feed of actual * these prefixes, which keeps a demo from looking like a feed of actual
* traffic. Nothing here is observed, and `flights.ts` explains at length why * traffic. Nothing here is observed, and `flights.ts` explains at length why
* this project ships a simulator instead of a client for somebody's live data. * this project ships a simulator instead of a client for somebody's live data.
*
* The unqualified name is a leftover and is kept because the app imports it.
* Every leg in it is over San Francisco, which is only the right answer for one
* of the two cities in this build; `sampleRoutesFor` is the entry point that
* knows the difference.
*/ */
export const SAMPLE_ROUTES: SimRoute[] = [ export const SAMPLE_ROUTES: SimRoute[] = [
{ callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 }, { callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
@@ -306,3 +312,46 @@ export const SAMPLE_ROUTES: SimRoute[] = [
{ callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 }, { callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
{ callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 }, { callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
]; ];
/**
* Sample traffic over the Los Angeles basin.
*
* The same idea as `SAMPLE_ROUTES` and it exists because that one was being
* flown over both cities: the SoCal board would come up with a sky whose every
* aircraft was nearly six hundred kilometres north of it, off the edge of the world
* and therefore invisible. A city that renders an empty sky looks like a city
* whose flight layer failed.
*
* Again the geography is roughly right and the callsigns are invented. LAX runs
* west almost always, so the arrivals here come in from the east over the
* basin and the departures go out over the water before turning; Burbank sits
* up the valley behind the hills, and the light aircraft is following the coast
* because in this basin that is what they do.
*/
export const SAMPLE_SOCAL_ROUTES: SimRoute[] = [
{ callsign: "CONDOR 6", from: [34.02, -117.45], to: [33.945, -118.36], fromAlt: 3500, toAlt: 400, duration: 330 },
{ callsign: "CONDOR 21", from: [33.99, -117.29], to: [33.94, -118.33], fromAlt: 4100, toAlt: 450, duration: 360 },
{ callsign: "AVOCET 12", from: [33.945, -118.42], to: [34.15, -118.84], fromAlt: 500, toAlt: 5200, duration: 190 },
{ callsign: "AVOCET 30", from: [33.68, -117.87], to: [33.34, -118.4], fromAlt: 600, toAlt: 4800, duration: 210 },
{ callsign: "CURLEW 3", from: [33.36, -117.3], to: [33.69, -117.86], fromAlt: 4200, toAlt: 500, duration: 235 },
{ callsign: "TOWHEE 9", from: [34.33, -118.82], to: [34.2, -118.36], fromAlt: 3000, toAlt: 450, duration: 175 },
{ callsign: "CONDOR 44", from: [34.36, -118.66], to: [33.32, -117.28], fromAlt: 9200, toAlt: 9800, duration: 300 },
{ callsign: "SANDPIPER 4", from: [33.6, -118.02], to: [34.03, -118.62], fromAlt: 900, toAlt: 1100, duration: 330 },
];
/**
* The right sample sky for a city, and something defensible for a city nobody
* has drawn one for.
*
* Keyed on `city.id` rather than on position because the hand-placed corridors
* are the whole value here: knowing that arrivals come down the peninsula and
* that LAX departs to the west is knowledge about two named places, and there
* is no way to derive it from a bounding box. What *can* be derived is legs
* that are at least in the right region, which is what `syntheticRoutes` does
* and what any third city gets until somebody sits down with a chart.
*/
export function sampleRoutesFor(city: Pick<City, "id" | "center" | "bounds">): SimRoute[] {
if (city.id === "sf") return SAMPLE_ROUTES;
if (city.id === "socal") return SAMPLE_SOCAL_ROUTES;
return syntheticRoutes(regionOf(city));
}
+20 -2
View File
@@ -131,8 +131,26 @@ export function createBlocks(world: World): THREE.InstancedMesh {
const [lat, lng] = world.unproject(x, z); const [lat, lng] = world.unproject(x, z);
if (!world.pointInPolygon(lat, lng, district.polygon)) continue; if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
if (!world.isLand(lat, lng)) continue; /**
if (world.pointInAny(lat, lng, world.city.parks)) continue; * Land and parks come off the lattice; the district polygon does not.
*
* The three tests used to be three exhaustive polygon walks each, and
* on the Bay Area's 186k candidate lots that was 240 ms of the boot's
* main thread — the largest single item in it, spent re-deriving what
* the heightfield Worker had already worked out for the whole board.
* `isLandSampled` and `inParkSampled` read that answer and fall through
* to the exact test only on a lattice cell that straddles the edge, so
* the coastline and the park boundaries are still decided by the
* polygons; see `World.sampled`. Same 186k lots, 19 ms.
*
* The district stays exact because there is no mask for it: districts
* are not a property of the lattice, they overlap, and San Francisco
* declares fifty-two of them. It is also the cheap one — the polygons
* are a dozen vertices and the bounding box rejects almost everything,
* which is 47 ms against the coastline's 235.
*/
if (!world.isLandSampled(lat, lng)) continue;
if (world.inParkSampled(lat, lng)) continue;
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
// Cubed, so tall buildings stay rare and the skyline keeps a // Cubed, so tall buildings stay rare and the skyline keeps a
+190 -7
View File
@@ -16,7 +16,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Aircraft, FlightSource } from "./types.ts"; import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts"; import { seededRandom, type World } from "./world.ts";
/** A route the simulator flies: great-circle-ish, with a climb or descent. */ /** A route the simulator flies: great-circle-ish, with a climb or descent. */
@@ -31,6 +31,156 @@ export interface SimRoute {
duration: number; duration: number;
} }
// ---- Where the sky is -----------------------------------------------------
/** A point on the ground. `City.center` is one; so is a query to a feed. */
export interface Place {
lat: number;
lng: number;
}
/**
* The patch of sky a source is being asked about.
*
* A circle rather than the city's rectangle, because a circle is the query
* every traffic feed actually offers: adsb.lol and airplanes.live both take a
* point and a radius, and a receiver on a roof takes nothing at all and gives
* you whatever it can hear. Turning the board into a circle here means the
* shape that crosses the wire is the shape the upstream wants, rather than a
* rectangle each adapter has to circumscribe on its own and get subtly
* different.
*
* This type exists because for a while the server was the only thing that knew
* where the traffic was one `TERA_ORIGIN_LAT/LNG` pair, fixed at boot, for a
* map with two metros nearly six hundred kilometres apart. Every viewer of
* the SoCal board was being handed San Francisco's aircraft, which do not
* merely look wrong: they project to scene coordinates a long way off the board
* and the sky comes up empty. Where to look is a parameter now, and it comes
* from the city being rendered.
*/
export interface SkyRegion {
center: Place;
/** Nautical miles from `center`, because that is the unit ADS-B feeds take. */
radiusNm: number;
}
/**
* One nautical mile is one minute of latitude. That is the definition of the
* unit, not an approximation of it, which is why there is no fudge factor here.
*/
const NM_PER_DEGREE = 60;
/**
* Distance in nautical miles, on a flat earth.
*
* Equirectangular rather than haversine, deliberately. This runs once per
* aircraft per poll several hundred times a second in the worst case a busy
* live feed can produce and over the hundred kilometres a city board spans
* the two answers differ by well under a tenth of a percent. Nothing
* downstream is measuring anything: the answers feed a radius query and an
* is-this-on-my-board test, and both carry slack counted in tens of kilometres.
*/
export function distanceNm(from: Place, to: Place): number {
const dLat = to.lat - from.lat;
const dLng = (to.lng - from.lng) * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLng) * NM_PER_DEGREE;
}
/**
* The circle that covers a city's board, measured from the city's own centre.
*
* Not from the centre of `bounds`, which is a different point: San Francisco's
* `center` is the city and its board runs forty kilometres down the peninsula,
* so the two are about twenty kilometres apart. The radius is therefore taken
* to the furthest of the four corners, and a circle drawn from that far
* off-centre reaches well past the board on the near side.
*
* That is the right error to make. Aircraft on approach are outside the board
* by definition and are the ones worth watching; a query clipped to the
* rendered rectangle would drop every arrival at the moment it became
* interesting and pop it into existence over the runway. `marginNm` is more of
* the same, and is why the default is not zero.
*/
export function regionOf(city: Pick<City, "center" | "bounds">, marginNm = 15): SkyRegion {
const { minLat, maxLat, minLng, maxLng } = city.bounds;
const corners: Place[] = [
{ lat: minLat, lng: minLng },
{ lat: minLat, lng: maxLng },
{ lat: maxLat, lng: minLng },
{ lat: maxLat, lng: maxLng },
];
let radiusNm = 0;
for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner));
return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) };
}
/** Whether a position is in the region, with optional slack in nautical miles. */
export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean {
return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm;
}
/**
* Plausible traffic for a region nobody has authored routes for.
*
* `adapters/sample.ts` has hand-placed corridors for the two cities in this
* build and they are much better than this: real arrivals come down the real
* approach, and that is most of what makes a sky read as *this* city's sky
* rather than as motion. What follows is what a third city gets on the day it
* is added and before anybody has done that work chords across the region at
* airliner altitudes, deterministic from the seed so that two viewers agree
* about where everything is.
*
* The alternative floor was an empty sky, and an empty sky over a city is not
* read as "no traffic today", it is read as a broken layer. Every leg here is
* inside the region by construction, which is the one property the previous
* arrangement could not offer: the constant it used was San Francisco.
*/
export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] {
const rand = seededRandom(seed);
const degPerNm = 1 / NM_PER_DEGREE;
// Longitude degrees are shorter than latitude degrees everywhere but the
// equator, so an eastwest offset in nautical miles is more of them.
const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180);
const routes: SimRoute[] = [];
for (let i = 0; i < count; i++) {
const bearing = rand() * Math.PI * 2;
// Push the chord off the centre so the legs are not six spokes through
// downtown. ±60% of the radius crosses the board at a spread of depths.
const offset = (rand() * 1.2 - 0.6) * region.radiusNm;
const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1));
const alongE = Math.sin(bearing);
const alongN = Math.cos(bearing);
const from = {
lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm,
};
const to = {
lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm,
};
// A third arriving, a third departing, a third crossing high. A board where
// everything is at cruise has no altitude ramp to read and no reason for
// the colour band in `createFlightLayer` to exist.
const kind = i % 3;
const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800;
const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400;
// Eight seconds a nautical mile is about 450 knots, which is an airliner.
const duration = Math.round(half * 2 * 8);
routes.push({
callsign: `SIM ${i + 1}`,
from: [from.lat, from.lng],
to: [to.lat, to.lng],
fromAlt: Math.round(fromAlt),
toAlt: Math.round(toAlt),
duration,
});
}
return routes;
}
/** /**
* Traffic that behaves like the real thing without being it: aircraft move * Traffic that behaves like the real thing without being it: aircraft move
* along fixed legs at fixed speeds, looping, with each one offset in phase so * along fixed legs at fixed speeds, looping, with each one offset in phase so
@@ -86,6 +236,18 @@ function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000; return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
} }
/**
* How long a snapshot is still worth drawing after the feed stops answering.
*
* A minute, which at this source's eight-second interval is seven missed polls
* in a row well past a dropped request and into "the feed is gone". Below
* that the last snapshot is held, because the alternative is that one timeout
* empties the sky, `createFlightLayer` drops every track it was interpolating,
* and the next good poll builds them all again from scratch: a full-screen
* flicker of every aircraft and every trail, caused by nothing.
*/
const ADSB_HOLD_SECONDS = 60;
/** /**
* Community ADS-B, for when real traffic is wanted. * Community ADS-B, for when real traffic is wanted.
* *
@@ -93,23 +255,36 @@ function nowSeconds(): number {
* volunteer-fed ADS-B and are the sources this project can point at without a * volunteer-fed ADS-B and are the sources this project can point at without a
* licence problem. The best answer long-term is an RTL-SDR on a fleet box: * licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with. * first-party data, nothing to comply with.
*
* The region is required and has no default. It used to default to a point in
* San Francisco, which is a fine centre for one of the two cities in this build
* and a five-hundred-kilometre error for the other and a wrong default is
* worse than a missing one, because it produces a sky rather than a type error.
*/ */
export class AdsbFlights implements FlightSource { export class AdsbFlights implements FlightSource {
readonly interval = 8; readonly interval = 8;
private held: Aircraft[] = [];
private heldAt = 0;
constructor( constructor(
private readonly endpoint: string, private readonly endpoint: string,
private readonly radiusNm = 25, private readonly region: SkyRegion,
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
) {} ) {}
async poll(): Promise<Aircraft[]> { async poll(): Promise<Aircraft[]> {
const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`; const { lat, lng } = this.region.center;
const url = `${this.endpoint}/v2/point/${lat}/${lng}/${Math.round(this.region.radiusNm)}`;
try { try {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return []; if (!res.ok) return this.hold();
const body = (await res.json()) as { ac?: RawAircraft[] }; const body = (await res.json()) as { ac?: RawAircraft[] };
return (body.ac ?? []) this.held = (body.ac ?? [])
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number") .filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
// The endpoint takes a radius and is trusted to honour it, but a
// receiver feeding one of these networks hears whatever it hears and
// some deployments serve the lot. Anything outside the region projects
// to a scene coordinate off the board.
.filter((a) => inRegion(this.region, a.lat as number, a.lon as number))
.map((a) => ({ .map((a) => ({
id: a.hex ?? `${a.flight ?? "?"}`, id: a.hex ?? `${a.flight ?? "?"}`,
callsign: a.flight?.trim(), callsign: a.flight?.trim(),
@@ -119,11 +294,19 @@ export class AdsbFlights implements FlightSource {
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000, altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
heading: typeof a.track === "number" ? a.track : 0, heading: typeof a.track === "number" ? a.track : 0,
})); }));
this.heldAt = nowSeconds();
return this.held;
} catch { } catch {
// A dead feed must not take the render loop with it. // A dead feed must not take the render loop with it.
return []; return this.hold();
} }
} }
/** The last snapshot, until it is old enough that an empty sky is the truth. */
private hold(): Aircraft[] {
if (nowSeconds() - this.heldAt > ADSB_HOLD_SECONDS) this.held = [];
return this.held;
}
} }
interface RawAircraft { interface RawAircraft {
+89 -16
View File
@@ -8,13 +8,23 @@
* one renderer serve a private map coloured by pipeline state and a public one * one renderer serve a private map coloured by pipeline state and a public one
* coloured by sector without either being a fork. * coloured by sector without either being a fork.
* *
* The renderer and the loop live in `Stage`; the camera, lights, flights and * The renderer and the loop live in `Stage`, which is **handed in and not
* picking live in a `SceneKit`. What is left here and it is the only thing * built here**: one renderer serves the canvas for the life of the page, and a
* that ought to be here is the city itself: which layers go in the scene, * city is a thing that is put on it and taken off again. Building a Stage per
* where a chapter puts the camera, and what a pick means. An office builds the * city is what this function used to do, and `stage.ts` records what it cost
* same two pieces with its own answers and swaps in on the same `Stage`, which * every switch orphaned a 2048² shadow map on the GL context, because
* keeps this city alive and paused rather than rebuilding its heightfield for * `WebGLRenderer.dispose()` frees none of a renderer's own textures.
* the Bay Area, about 2.3 s on the way back. See CONTRACT.md §1. *
* The camera, lights, flights and picking live in a `SceneKit`. What is left
* here and it is the only thing that ought to be here is the city itself:
* which layers go in the scene, where a chapter puts the camera, and what a
* pick means. An office builds the same two pieces with its own answers and
* swaps in on the same `Stage`, which keeps this city alive and paused rather
* than rebuilding it on the way back.
* For the Bay Area that is about 2.2 s of layer construction, of which roughly
* 730 ms is the heightfield and the heightfield is now the only part of it
* that happens off the main thread, so a rebuild would be 2.2 s of *frozen*
* page rather than 2.2 s of busy one. See CONTRACT.md §1.
*/ */
import * as THREE from "three"; import * as THREE from "three";
@@ -23,7 +33,7 @@ import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createSceneKit, type Pose } from "./scenekit.ts"; import { createSceneKit, type Pose } from "./scenekit.ts";
import { createStage, type Stage, type StageScene } from "./stage.ts"; import type { Stage, StageScene } from "./stage.ts";
import { createBridges, createRoads } from "./structures.ts"; import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type { import type {
@@ -35,7 +45,7 @@ import type {
MarkerPalette, MarkerPalette,
ScenePalette, ScenePalette,
} from "./types.ts"; } from "./types.ts";
import { World } from "./world.ts"; import { World, type FieldProgress } from "./world.ts";
export interface SceneOptions { export interface SceneOptions {
city: City; city: City;
@@ -49,15 +59,32 @@ export interface SceneOptions {
* until somebody wires up the sun is not a scene that boots with no config. * until somebody wires up the sun is not a scene that boots with no config.
*/ */
lighting?: LightingState; lighting?: LightingState;
/**
* Fires while the heightfield builds, several times a second. The caller
* decides what to say about it; the engine only reports a phase and a
* fraction. See `FieldProgress`.
*/
onProgress?: (progress: FieldProgress) => void;
/**
* Abandons the build. `createScene` then resolves to `null` having allocated
* no geometry and having touched the stage not at all the point of aborting
* is that the next city gets the machine to itself, and a half-built scene
* parked on the stage defeats that.
*/
signal?: AbortSignal;
} }
export interface SceneHandle { export interface SceneHandle {
world: World; world: World;
chapters: Chapter[]; chapters: Chapter[];
/** /**
* The renderer and the loop. An office is swapped in with * The stage this city was built on, which it uses and does not own. An office
* `stage.setScene(officeScene)` and this city back in the same way; the one * is swapped in with `stage.setScene(officeScene)` and this city back in the
* that steps out is paused, not thrown away. * same way; the one that steps out is paused, not thrown away.
*
* `dispose()` below takes the city off it and leaves it running. Whoever
* built the stage disposes it, and on this page nobody does it lives as
* long as the canvas does.
*/ */
stage: Stage; stage: Stage;
/** This city, as the thing `stage.setScene` takes. */ /** This city, as the thing `stage.setScene` takes. */
@@ -74,15 +101,45 @@ export interface SceneHandle {
current(): string; current(): string;
onChapterChange(fn: (id: string) => void): void; onChapterChange(fn: (id: string) => void): void;
setMarkers(markers: Marker[]): void; setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void; dispose(): void;
} }
export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle { /**
* Build a city on a stage. Resolves to `null` if the build was abandoned.
*
* ## Why this is async, and why the handle is not
*
* `SceneHandle` is unchanged: every method on it is synchronous and every field
* on it is real by the time you hold one. Only getting one takes a moment.
*
* The alternative was tried on paper and is worse. Handing back a handle
* immediately means handing back a handle whose `stageScene` holds an empty
* scene, whose `flyTo` cannot know where the ground is, and whose `world`
* answers `groundAt` by building the heightfield on the main thread the exact
* block this change exists to remove, reintroduced by the first caller who
* forgets to wait. Every one of those methods would need a "not yet" branch and
* a queue, and the queue would be the real API.
*
* So the wait is where the wait actually is. The cost is that it ripples: the
* app has to `await mountCity`, and `createMinimap` has to be constructed after
* this resolves rather than alongside it. That is a handful of `await`s in
* `main.ts` against an engine that cannot lie about whether its ground exists.
*/
export async function createScene(
stage: Stage,
options: SceneOptions,
): Promise<SceneHandle | null> {
const { city } = options; const { city } = options;
const world = new World(city); const world = new World(city);
// First, so an abandoned build has nothing to tear down: everything below
// this line allocates, and a city that is no longer wanted should not have
// built a single buffer.
const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress });
if (!ready) return null;
const pal = paletteFor(world); const pal = paletteFor(world);
const stage = createStage(canvas);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
/** /**
@@ -238,8 +295,24 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
markerLayer.setMarkers(markers); markerLayer.setMarkers(markers);
}, },
dispose() { dispose() {
// Stage first, so nothing ticks a half-disposed scene. /**
stage.dispose(); * Off the stage, then released and the stage itself is left running.
*
* The order is load-bearing and it used to be the other way round, with
* a comment saying "Stage first, so nothing ticks a half-disposed scene".
* The concern was real and the remedy was the bug: `stage.dispose()`
* calls `renderer.dispose()`, which replaces the `properties` WeakMap,
* and `stageScene.dispose()` then walks the scene disposing materials
* that the renderer no longer has an entry for. `three` reads
* `properties.get(material).programs`, finds `undefined`, and quietly
* skips `gl.deleteProgram` for every one of them so disposing in that
* order freed nothing it was written to free.
*
* `setScene(null)` answers the ticking concern on its own and answers it
* better: the loop drops this scene on the very next frame, and the
* renderer keeps its bookkeeping so the disposals below actually land.
*/
if (stage.current() === stageScene) stage.setScene(null);
stageScene.dispose(); stageScene.dispose();
}, },
}; };
+249 -7
View File
@@ -12,12 +12,46 @@
* the sun `Atmosphere` for a city, a fixed constant for an office computes * the sun `Atmosphere` for a city, a fixed constant for an office computes
* the state and hands it over, and nothing writes back. That is the one * the state and hands it over, and nothing writes back. That is the one
* direction CONTRACT.md §4 asks for. * direction CONTRACT.md §4 asks for.
*
* It is also where a finger meets the map. `OrbitControls` gives one gesture
* vocabulary to both a mouse and a thumb, and the two want different answers
* so the kit swaps a small input profile on every `pointerdown` according to
* `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop
* changes; the touch values are only ever installed by a touch.
*
* The one thing that is *not* here is `touch-action`. `OrbitControls.connect()`
* sets `touchAction = "none"` on the element it is handed, and `index.html`
* also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule
* is what covers the second or two between first paint and this module
* existing, and a drag on the canvas in that window would otherwise scroll and
* rubber-band the page instead.
*/ */
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts"; import type { LightingState } from "./types.ts";
/**
* How much slower one finger turns the camera than one mouse.
*
* `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes,
* and it has one `rotateSpeed` for both, so this is a compromise between them.
* Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall
* phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle`
* leaves about 85° of usable travel against a mapping that spends 360° over a
* screen height, so a tilt hits its clamp in the first 200 px and the camera
* feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px
* and costs the azimuth a swing it can afford the vertical axis is the
* binding constraint, and there is only one dial.
*/
const TOUCH_ROTATE_SCALE = 0.7;
/** How far a finger may wander and still be a tap, in CSS px. */
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */ /** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose { export interface Pose {
position: THREE.Vector3; position: THREE.Vector3;
@@ -101,16 +135,108 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
const controls = new OrbitControls(camera, dom); const controls = new OrbitControls(camera, dom);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = options.dampingFactor ?? 0.07; const baseDamping = options.dampingFactor ?? 0.07;
controls.dampingFactor = baseDamping;
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = options.minDistance ?? 12; controls.minDistance = options.minDistance ?? 12;
controls.maxDistance = options.maxDistance ?? 340; controls.maxDistance = options.maxDistance ?? 340;
// ---- Input --------------------------------------------------------------
/*
* The gesture map is three.js's default and it is already the right one:
* `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers
* pinch and drag *at the same time*, which is how every map on a phone
* behaves and is why it is not split into separate two-finger modes here.
*
* Zoom needs nothing scaled to the board, and it is worth saying why, because
* `scene.ts` records what happened the last time a distance was treated as a
* constant. A pinch dollies by `(endSeparation / startSeparation) ^
* zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both
* multiply the camera's current distance, so SoCal's 393-unit board and the
* Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with
* no knowledge of either number. The only board-sized values in the gesture
* path are `minDistance` and `maxDistance`, which the caller already derives.
*/
/**
* A mouse and a thumb are given different values for the three settings where
* one answer cannot serve both, swapped in on `pointerdown` by `pointerType`.
*
* The alternative pick the values once from a device probe is wrong on
* every laptop with a touchscreen, where both inputs are live at once and the
* user switches between them mid-session. Keying off the event that is
* actually happening is both simpler and correct, and it means the desktop
* path is bit-for-bit what it was: the touch values do not exist until a
* touch installs them.
*
* - **`screenSpacePanning`** is three's default `true`, which pans along the
* camera's own up vector. On a map seen from above that lifts the target
* off the ground as you drag, and the board slides away underneath. For two
* fingers it goes to `false`: pan in the ground plane, so the board tracks
* the fingers. Left alone for the mouse, where right-drag pan is
* long-standing behaviour and someone would notice it change.
* - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point
* between the fingers, which is the whole reason people pinch a particular
* neighbourhood. It moves `controls.target` as well as the camera, so the
* orbit centre drifts toward whatever was pinched accepted deliberately,
* because on a map that drift *is* the interaction. The wheel keeps zooming
* to the centre of the view.
* - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`.
*/
const mouseInput = {
rotateSpeed: controls.rotateSpeed,
screenSpacePanning: controls.screenSpacePanning,
zoomToCursor: controls.zoomToCursor,
};
function applyPointerProfile(pointerType: string) {
const touch = pointerType === "touch";
controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1);
controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning;
controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor;
}
/**
* A wheel arrives with no pointer, so it cannot announce its own type. Any
* wheel at all means a mouse or a trackpad is in the room, and without this a
* hybrid laptop that was last touched keeps the touch profile and scrolls
* toward wherever the finger happened to be, once, for no visible reason.
*
* `OrbitControls` registered its own wheel handler first, so the notch that
* performs the reset is itself still anchored to the old point and only the
* next one is centred. One notch, on a machine that has both inputs and used
* both in the same breath; the fix for that costs finger-counting state and
* buys a frame.
*/
function onWheel() {
applyPointerProfile("mouse");
}
dom.addEventListener("wheel", onWheel, { passive: true });
/**
* iOS pinches the *page* as well as the map.
*
* `touch-action: none` stops Safari's double-tap zoom and its scroll, but
* WebKit's own `gesture*` events are not covered by it, and a two-finger
* pinch that begins on the canvas can still scale the whole document
* leaving the UI enormous, half off-screen, and with no gesture left that
* undoes it. Refusing the three of them costs nothing anywhere else: no other
* engine implements the events at all.
*/
const preventGesture = (event: Event) => event.preventDefault();
dom.addEventListener("gesturestart", preventGesture);
dom.addEventListener("gesturechange", preventGesture);
dom.addEventListener("gestureend", preventGesture);
// ---- Light rig ---------------------------------------------------------- // ---- Light rig ----------------------------------------------------------
const sun = new THREE.DirectionalLight(0xffffff, 1); const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.castShadow = true; sun.castShadow = true;
const mapSize = options.shadowMapSize ?? 2048; // The default is the device's, not a constant: a phone gets a smaller map for
// the reasons written out in `stage.ts`. A caller that knows better — an
// office, at a hundredth of the city's scale — passes its own.
const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize;
sun.shadow.mapSize.set(mapSize, mapSize); sun.shadow.mapSize.set(mapSize, mapSize);
sun.shadow.camera.near = options.shadowNear ?? 10; sun.shadow.camera.near = options.shadowNear ?? 10;
sun.shadow.camera.far = options.shadowFar ?? 520; sun.shadow.camera.far = options.shadowFar ?? 520;
@@ -175,6 +301,19 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
let flying = false; let flying = false;
let flightT = 0; let flightT = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
// Mid-flight when the preference flips: land now rather than finish the arc.
if (reducedMotion && flying) setPose(to);
}
motionQuery?.addEventListener("change", onMotionChange);
function setPose(pose: Pose) { function setPose(pose: Pose) {
flying = false; flying = false;
camera.position.copy(pose.position); camera.position.copy(pose.position);
@@ -182,7 +321,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
controls.update(); controls.update();
} }
/**
* A chapter flight is the largest motion this app makes: the whole field of
* view sweeps and rotates for a second and a half, unrequested by anyone who
* only clicked a name in a list. That is the case `prefers-reduced-motion`
* exists for, so under it the flight becomes a cut. `main.ts` already reached
* the same conclusion for a minimap seek and says so there.
*
* Damping is left alone, and the distinction is worth stating: damping only
* ever follows a finger or a mouse that is currently moving, and it settles
* in a few frames after it stops. It is the response to a gesture, not motion
* the interface started on its own.
*/
function flyTo(pose: Pose) { function flyTo(pose: Pose) {
if (reducedMotion) {
setPose(pose);
return;
}
from.position.copy(camera.position); from.position.copy(camera.position);
from.target.copy(controls.target); from.target.copy(controls.target);
to.position.copy(pose.position); to.position.copy(pose.position);
@@ -203,14 +358,68 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
// thrown away. // thrown away.
let pointerDirty = false; let pointerDirty = false;
function onPointerMove(event: PointerEvent) { function aimAt(clientX: number, clientY: number) {
const rect = dom.getBoundingClientRect(); const rect = dom.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
pointerDirty = true; pointerDirty = true;
} }
// A moving finger is not hovering; see the tap block below.
function onPointerMove(event: PointerEvent) {
if (event.pointerType === "touch") return;
aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointermove", onPointerMove); dom.addEventListener("pointermove", onPointerMove);
/**
* There is no hover on a touch screen, and pretending otherwise is how a map
* ends up flashing a detail card for every marker a thumb happens to sweep
* across on its way to turning the board. A finger only reports where it is
* *while it is pressed*, which is exactly when it is doing something else.
*
* So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and
* `TAP_MS`, and that point is picked. Anything longer or further is a gesture
* and picks nothing. The pick then survives the finger leaving the glass a
* card raised by a tap has to stay up to be read and is cleared by the next
* touch anywhere, which is what makes tapping empty water the way to dismiss
* it.
*
* 12 px of slop, not zero: a thumb pivots while it presses, and a tap that
* wandered a millimetre is still a tap. Past that the camera has visibly
* moved, and something that moved the map should not also have selected
* something on it.
*/
/** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */
let tapPointer = -1;
let tapX = 0;
let tapY = 0;
let tapAt = 0;
function onPointerDown(event: PointerEvent) {
applyPointerProfile(event.pointerType);
if (event.pointerType !== "touch") return;
resetPick();
tapPointer = tapPointer === -1 ? event.pointerId : -2;
tapX = event.clientX;
tapY = event.clientY;
tapAt = event.timeStamp;
}
dom.addEventListener("pointerdown", onPointerDown);
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP;
tapPointer = -1;
if (wasTap) aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointerup", onPointerUp);
dom.addEventListener("pointercancel", onPointerUp);
function resetPick() { function resetPick() {
pointerDirty = false; pointerDirty = false;
if (picked === null) return; if (picked === null) return;
@@ -219,7 +428,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
dom.style.cursor = ""; dom.style.cursor = "";
wasPicking?.onChange(null); wasPicking?.onChange(null);
} }
dom.addEventListener("pointerleave", resetPick);
// Not for touch. A finger lifting fires `pointerleave` immediately after
// `pointerup`, so honouring it here would wipe the pick a tap had just made,
// in the same frame, every time.
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
resetPick();
}
dom.addEventListener("pointerleave", onPointerLeave);
function repick() { function repick() {
if (!picking || !pointerDirty) return; if (!picking || !pointerDirty) return;
@@ -253,6 +470,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
}, },
resetPick, resetPick,
tick(dt) { tick(dt) {
/**
* `OrbitControls` damps per *frame*, not per second: every `update()`
* moves the camera `dampingFactor` of the way to where the input asked
* for. So the same 0.07 is a different feel on every refresh rate twice
* as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a
* 144 Hz monitor, which is why the settle on a laptop and the settle on a
* handset never matched.
*
* Re-deriving it from the frame time fixes both ends with the same line.
* At exactly 60 fps this returns `baseDamping` unchanged, so the desktop
* default it was tuned at is preserved to the digit; away from 60 it
* holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to
* 50 ms, so the exponent cannot run away after a stall and snap the
* camera.
*/
controls.dampingFactor =
dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping;
if (flying) { if (flying) {
flightT = Math.min(1, flightT + dt * flightSpeed); flightT = Math.min(1, flightT + dt * flightSpeed);
// easeInOutCubic — a flight that starts and lands gently // easeInOutCubic — a flight that starts and lands gently
@@ -268,7 +502,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
}, },
dispose() { dispose() {
dom.removeEventListener("pointermove", onPointerMove); dom.removeEventListener("pointermove", onPointerMove);
dom.removeEventListener("pointerleave", resetPick); dom.removeEventListener("pointerdown", onPointerDown);
dom.removeEventListener("pointerup", onPointerUp);
dom.removeEventListener("pointercancel", onPointerUp);
dom.removeEventListener("pointerleave", onPointerLeave);
dom.removeEventListener("wheel", onWheel);
dom.removeEventListener("gesturestart", preventGesture);
dom.removeEventListener("gesturechange", preventGesture);
dom.removeEventListener("gestureend", preventGesture);
motionQuery?.removeEventListener("change", onMotionChange);
dom.style.cursor = ""; dom.style.cursor = "";
picking = null; picking = null;
controls.dispose(); controls.dispose();
+166 -4
View File
@@ -14,6 +14,32 @@
* rebuild on the way back out. Stage therefore disposes nothing it did not * rebuild on the way back out. Stage therefore disposes nothing it did not
* create whoever built a `StageScene` disposes it, when they actually mean * create whoever built a `StageScene` disposes it, when they actually mean
* to be rid of it. See CONTRACT.md §1. * to be rid of it. See CONTRACT.md §1.
*
* ## One Stage per canvas, for the life of the page
*
* The corollary, and it is not optional. `createScene` used to build a Stage of
* its own per city, so every switch between the Bay Area and SoCal constructed
* another `WebGLRenderer` on the same GL context and abandoned the last one.
* `WebGLRenderer.dispose()` frees **no textures at all** read it in
* `three.module.js`: `background`, `renderLists`, `renderStates`, `properties`,
* `objects`, `programCache` and the rest, and not `textures` so each switch
* orphaned the renderer's seven 1x1 defaults and its 2048² shadow map. That is
* 16.8 MB of GPU memory per switch, invisible to a JS heap snapshot, plus about
* ten shader programs, growing monotonically and never plateauing: ten switches
* measured 88 live textures and 117 live programs against 0 calls to
* `gl.deleteTexture`.
*
* There is no version of this that `dispose()` fixes, because the leaked
* textures are the renderer's own and it does not free them. The only fix is
* not to build a second renderer, so the app constructs one Stage next to the
* canvas and hands it to every scene it builds. `createScene` takes a `Stage`
* rather than a canvas for that reason, and the office already worked this way.
*
* It also decides how much machine there is to spend, because the renderer is
* what spends it: `deviceProfile()` below is the single place that answers
* "is this a phone", and `scenekit.ts` imports it rather than asking again, so
* the two halves of the engine cannot end up with different opinions about the
* same handset.
*/ */
import * as THREE from "three"; import * as THREE from "three";
@@ -31,25 +57,155 @@ export interface StageScene {
export interface Stage { export interface Stage {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
setScene(s: StageScene): void; /**
* Show a scene, or `null` for none at all.
*
* `null` is what a caller about to dispose a scene passes first: the loop
* stops touching it that instant, which is the whole of the "nothing ticks a
* half-disposed scene" rule, and it costs no renderer state. The stage keeps
* running with nothing to draw, which is exactly what it does between the
* first frame and the first city.
*/
setScene(s: StageScene | null): void;
current(): StageScene | null; current(): StageScene | null;
/**
* Retire the renderer and the loop.
*
* Called once, at the end of the page's life, by whoever built it which is
* **not** a scene. See the note at the top of this file about what
* `WebGLRenderer.dispose()` does and does not free.
*/
dispose(): void; dispose(): void;
} }
export interface StageOptions { export interface StageOptions {
antialias?: boolean; antialias?: boolean;
/** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */ /** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
maxPixelRatio?: number; maxPixelRatio?: number;
shadows?: boolean; shadows?: boolean;
} }
/**
* What kind of machine this is, to the extent a browser will say.
*
* There is no honest way to ask a page how fast its GPU is. The two things
* usually reached for are both worse than useless here:
* `navigator.hardwareConcurrency` counts CPU threads, and a phone with eight
* of them and a phone with four tell you nothing about their fill rate
* Safari also rounds it and Chrome caps it, so the same handset answers
* differently in two browsers. `navigator.deviceMemory` is Chromium-only and
* bucketed to powers of two. Neither is a proxy for the thing being decided.
*
* So this does not pretend to measure performance. It asks the one question it
* can answer correctly *is this a phone* out of a coarse primary pointer
* and a short viewport edge, and applies a fixed, documented budget to that
* answer. A tablet is not a phone: `(pointer: coarse)` is true on an iPad and
* its short edge is 820, so it lands on the desktop budget, which is right
* because it has the screen and usually the silicon for it.
*
* The 600px edge is deliberately `index.html`'s own small breakpoint, so the
* renderer's idea of "phone" and the stylesheet's cannot drift apart.
*
* Sampled once, by whoever constructs. Re-deriving it on resize would let a
* rotation or a desktop window drag change the pixel ratio mid-session, which
* costs a full reallocation of every render target to buy nothing.
*/
export interface DeviceProfile {
/** Coarse pointer and a short viewport edge. A phone, as far as anyone can tell. */
handheld: boolean;
/** Device pixel ratio ceiling. */
maxPixelRatio: number;
/** Default shadow map edge, in texels. Read by `scenekit.ts`. */
shadowMapSize: number;
}
export function deviceProfile(): DeviceProfile {
const coarse =
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
const shortEdge = Math.min(window.innerWidth, window.innerHeight);
const handheld = coarse && shortEdge <= 600;
/**
* 1.5, not 2, on a phone and not 1 either.
*
* A 390 x 844 iPhone reports a device pixel ratio of 3. Capped at 2 that is
* 780 x 1688, 1.3 megapixels of fragments, every one of them shaded against
* a sun, a hemisphere, an ambient and a shadow lookup, for a scene carrying
* about 140k building instances. At 1.5 it is 585 x 1266, 0.74 Mpx: 56% of
* the fragments for a frame that is still supersampled relative to CSS
* pixels. Dropping to 1 would halve it again, but then a 3x panel is
* upscaling by three and the whole map goes soft which reads as a cheap
* page rather than a fast one.
*
* The antialias flag stays on there. MSAA on the tile-based GPUs in phones
* resolves inside tile memory and is close to the cheapest edge quality
* available; raising the pixel ratio to buy the same smoothing costs
* quadratically. Spend it on MSAA, not on pixels.
*/
return {
handheld,
maxPixelRatio: handheld ? 1.5 : 2,
/*
* Halved on a phone, and the city barely knows.
*
* Check what is actually in that map before defending its size. On the city
* board the only casters are the buildings, the landmarks and the bridges
* `terrain.ts` sets `receiveShadow` and never `castShadow`, so the hills'
* relief is the Lambert term and not a shadow at all. And `scene.ts` hands
* the kit a shadow extent of 0.75 board spans, which for the Bay Area's
* 1003 units is a 1504-unit box: at 2048 texels that is 0.73 units, about
* 69 m at this city's scale, and a building footprint is one texel or less.
* The map is already quantising past the things in it.
*
* So 1024 on a phone costs the map a resolution it was not using. An office
* passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is
* four centimetres a texel and a desk very much does cast.
*/
shadowMapSize: handheld ? 1024 : 2048,
};
}
export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage { export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage {
const profile = deviceProfile();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2)); renderer.setPixelRatio(
Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
if (options.shadows ?? true) { if (options.shadows ?? true) {
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; /**
* `PCFShadowMap`, and phones get it too.
*
* This line said `PCFSoftShadowMap` and had done since the first commit,
* which in three r182 is not soft and is not PCF. Two things happened
* upstream. `WebGLProgram`'s define table now maps only `PCFShadowMap` and
* `VSMShadowMap`, and everything else falls through to
* `SHADOWMAP_TYPE_BASIC` one unfiltered tap, hard stair-stepped edges.
* The runtime downgrade that is supposed to catch this reads `lights.type`
* off the light *array* rather than off the shadow map, so it is always
* `undefined` and the deprecation warning never prints. The result was the
* cheapest and ugliest shadows in the library, chosen by nobody, announced
* to no one.
*
* `PCFShadowMap` costs five Vogel-disk samples through a hardware
* comparison sampler, with the pattern rotated per pixel by interleaved
* gradient noise. That is more than one tap, and it is the reason a phone
* can be given a 1024 map (see `deviceProfile`) and still look better than
* it did on an unfiltered 2048: filtering buys more here than resolution
* does, because at this board's shadow extent the map quantises to a city
* block either way.
*
* Switching shadows off on a phone was the other option and it cannot be
* taken at this line. This flag is the *renderer's*, and the renderer is
* shared: the office swaps onto the same `Stage` (CONTRACT.md §1) at a
* hundredth of the city's scale, where the shadows under the desks are the
* whole read of depth in the room. Killing them here to speed up a map that
* is quantising them away anyway would gut Spaces on the one class of
* device that most needs Spaces to be worth the download. The saving lives
* in the map size instead, which each scene chooses for itself.
*/
renderer.shadowMap.type = THREE.PCFShadowMap;
} }
let currentScene: StageScene | null = null; let currentScene: StageScene | null = null;
@@ -62,6 +218,11 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
// Compared against CSS pixels, because `canvas.width` is in device pixels and // Compared against CSS pixels, because `canvas.width` is in device pixels and
// differs from `clientWidth` on every retina display — checking it would call // differs from `clientWidth` on every retina display — checking it would call
// `setSize` on every single frame. // `setSize` on every single frame.
//
// This is also what keeps mobile Safari honest. The canvas is `100dvh`, and
// the viewport grows and shrinks continuously as the URL bar collapses under
// a scroll-like gesture; that emits no `resize` event worth relying on. The
// per-frame comparison catches it as a size change like any other.
let lastWidth = 0; let lastWidth = 0;
let lastHeight = 0; let lastHeight = 0;
@@ -111,6 +272,7 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
if (s === currentScene) return; if (s === currentScene) return;
currentScene?.onExit?.(); currentScene?.onExit?.();
currentScene = s; currentScene = s;
if (!s) return;
// The incoming camera may never have seen this canvas, and the canvas may // The incoming camera may never have seen this canvas, and the canvas may
// have been resized while the scene was paused. // have been resized while the scene was paused.
applyViewport(s); applyViewport(s);
+11 -6
View File
@@ -46,16 +46,21 @@ export function paletteFor(world: World): ScenePalette {
* and McLaren, all of which are parks and get their green from being in * and McLaren, all of which are parks and get their green from being in
* `city.parks`. Everywhere else stays city-coloured however high it goes, and * `city.parks`. Everywhere else stays city-coloured however high it goes, and
* the buildings do the rest of the talking. * the buildings do the rest of the talking.
*
* `inPark` arrives as an argument rather than being worked out here. This used
* to call `world.pointInAny(lat, lng, world.city.parks)` itself, once per
* emitted vertex, which on the Bay Area is 294k walks of twenty-four park
* polygons 72 ms of main thread, on a desktop, recomputing a fact the Worker
* had already established at exactly these points on its way past. The lattice
* now carries it (`Field.park`), and the caller has the index in hand.
*/ */
function groundColor( function groundColor(
world: World,
pal: ScenePalette, pal: ScenePalette,
scratch: THREE.Color, scratch: THREE.Color,
lat: number, inPark: boolean,
lng: number,
elevation: number, elevation: number,
): THREE.Color { ): THREE.Color {
if (world.pointInAny(lat, lng, world.city.parks)) { if (inPark) {
return scratch return scratch
.setHex(pal.park) .setHex(pal.park)
.lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180)); .lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
@@ -110,7 +115,7 @@ export function createShorePlates(world: World): THREE.Mesh {
*/ */
export function createTerrain(world: World): THREE.Mesh { export function createTerrain(world: World): THREE.Mesh {
const pal = paletteFor(world); const pal = paletteFor(world);
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice();
const positions: number[] = []; const positions: number[] = [];
const colors: number[] = []; const colors: number[] = [];
@@ -130,7 +135,7 @@ export function createTerrain(world: World): THREE.Mesh {
const e = height[k] ?? 0; const e = height[k] ?? 0;
const [x, z] = world.project(lat, lng); const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z); positions.push(x, world.metres(e) + 0.012, z);
const c = groundColor(world, pal, scratch, lat, lng, e); const c = groundColor(pal, scratch, park[k] === 1, e);
colors.push(c.r, c.g, c.b); colors.push(c.r, c.g, c.b);
const id = positions.length / 3 - 1; const id = positions.length / 3 - 1;
vertexAt[k] = id; vertexAt[k] = id;
+87
View File
@@ -0,0 +1,87 @@
/**
* The heightfield, built off the main thread.
*
* This worker exists for one number: on the Bay Area, producing the lattice is
* about 730 ms of unbroken synchronous work, and SoCal's is around 390 ms on
* top of a switch that already blocks for four seconds. Nothing paints and
* nothing responds while it runs not the boot card, not the progress line
* explaining why the boot card is still up.
*
* It is deliberately thin. All the geography lives in `world.ts` and this file
* calls `computeField` exactly as the main thread would; the only thing here
* that is not in `world.ts` is the message plumbing. The alternative a second
* copy of the sampling loop, tuned separately produces two maps of the same
* city that differ in the fourth decimal place and agree on nothing that would
* make the difference visible.
*
* ## Why the `City` is sent whole
*
* A `City` is pure data by contract (`types.ts`), so structured clone carries it
* across as-is. That costs something the Bay Area pack's polygons are a few
* hundred kilobytes but it is paid once per city, against a field that comes
* back as several megabytes of transferred buffer. Sending a city *id* and
* importing the pack in here instead would drag both city modules into the
* worker chunk and break the rule that a pack is data the engine is handed,
* not data the engine knows about.
*/
import { World, computeField, type FieldMessage, type FieldRequest } from "./world.ts";
/**
* `DedicatedWorkerGlobalScope` is not in `lib.dom.d.ts` and this project's
* `tsconfig.json` belongs to another session, so the two members this file
* actually touches are declared here rather than by adding `WebWorker` to
* `lib`. Narrow on purpose: if this grows a third member, that is the moment to
* ask for the lib entry instead.
*/
declare const self: {
onmessage: ((event: MessageEvent<FieldRequest>) => void) | null;
postMessage(message: FieldMessage, transfer?: Transferable[]): void;
};
/**
* How often progress goes back over the wire.
*
* Per-row would be 656 messages for San Francisco, and every one of them is a
* task queued on the main thread the thread this whole file exists to leave
* alone. Eight a second is enough for a bar that moves and cheap enough to be
* invisible.
*/
const PROGRESS_INTERVAL_MS = 125;
self.onmessage = (event: MessageEvent<FieldRequest>) => {
const { city } = event.data;
try {
const world = new World(city);
let last = 0;
const field = computeField(world, (done, rows) => {
const now = performance.now();
if (done < rows && now - last < PROGRESS_INTERVAL_MS) return;
last = now;
self.postMessage({ type: "progress", done, rows });
});
// Transfer, do not copy. The Bay Area's field is a 2.1 MB `Float32Array`
// and two 533 kB `Uint8Array`s, plus the two axes; structured-cloning that
// back hands the main thread a memcpy and an allocation of everything the
// worker just saved it. After this the worker's own views are detached,
// which is fine because it is about to be terminated.
//
// Every buffer in the field is listed. A buffer left off this list is
// silently *copied* instead of moved, which is invisible in behaviour and
// is exactly the cost this postMessage exists to avoid.
self.postMessage({ type: "field", ...field }, [
field.lats.buffer,
field.lngs.buffer,
field.height.buffer,
field.land.buffer,
field.park.buffer,
]);
} catch (err) {
// Report rather than throw. An uncaught error in here reaches the main
// thread as an `ErrorEvent` with no message under most cross-origin rules,
// and "something went wrong somewhere" is not worth the fallback path being
// silent about.
self.postMessage({ type: "failed", message: err instanceof Error ? err.message : String(err) });
}
};
+464 -69
View File
@@ -5,10 +5,89 @@
* One `World` per city, built once. The engine's other modules take a `World` * One `World` per city, built once. The engine's other modules take a `World`
* rather than importing constants, which is the whole reason a second city is * rather than importing constants, which is the whole reason a second city is
* a data file and not a fork. * a data file and not a fork.
*
* ## Constructed immediately, ready later
*
* Everything here that does not touch the heightfield `project`, `metres`,
* `pointInPolygon`, `elevationAt` works the instant the constructor returns.
* The heightfield does not: it is half a million samples of four-octave noise
* and a distance-to-coastline, and on the Bay Area that is about 730 ms of
* unbroken main thread. It is built in a Worker, and `await world.ready()` is
* how a caller waits for it.
*
* The synchronous samplers stay synchronous, because `terrain.ts`, `blocks.ts`,
* `structures.ts`, `nightlights.ts` and `minimap.ts` call `groundAt` in tight
* loops and an `await` inside those loops would cost far more than the block it
* saved. So the split is: *becoming* ready is asynchronous, *being* ready is
* not.
*
* ## What the field carries, and why it grew
*
* Height was the first thing worth computing once and reading back, and for a
* while it was the only one. It was not the only one that was being recomputed:
* `isLand` and "is this in a park" are polygon walks over coastlines with
* hundreds of vertices, and the layer builders were asking them hundreds of
* thousands of times *on the main thread*, for points the Worker had already
* classified on its way past. So the field carries `land` and `park` too, and
* `isLandSampled`/`inParkSampled` read them. The exact predicates are still
* here, still exact, and are what the Worker itself uses.
*/ */
import type { City, LatLng } from "./types.ts"; import type { City, LatLng } from "./types.ts";
/** The lattice, and the three arrays sampled off it. */
export interface Field {
latSteps: number;
lngSteps: number;
/** Latitude of every row; spacing is not uniform. See `buildAxis`. */
lats: Float64Array;
/** Longitude of every column. */
lngs: Float64Array;
/** Metres above sea level, row-major, `(lngSteps + 1)` wide. */
height: Float32Array;
/** 1 where the point is on land, 0 in water. Same layout as `height`. */
land: Uint8Array;
/**
* 1 where the point is inside `city.parks`, 0 elsewhere and everywhere wet.
* Same layout as `height`.
*
* Here rather than left to the consumers because the loop that fills it is
* already standing on the point with the coordinate in hand, and because the
* consumers are on the main thread while this is not. See `computeField`.
*/
park: Uint8Array;
}
/**
* How the heightfield is getting on, for whoever is showing a boot card.
*
* `phase` is a stable key and not a sentence: the engine has no opinion about
* what language the page is in, and the one place that already writes this copy
* `#boot-step` is the app's, not the engine's.
*/
export interface FieldProgress {
phase: "heightfield";
/** 0..1. Rows completed, which is honest: every row costs about the same. */
fraction: number;
/**
* True when the build fell back to the main thread, so a caller can tell the
* difference between "this is slow" and "this is slow *and* the page is
* frozen, do not bother animating anything".
*/
onMainThread: boolean;
}
export interface ReadyOptions {
/**
* Abandons the build. The promise then resolves `false` rather than
* rejecting: switching city mid-build is a normal thing for a person to do,
* not an error, and a rejection would have to be caught at every call site
* or become an unhandled rejection in the console.
*/
signal?: AbortSignal;
onProgress?: (progress: FieldProgress) => void;
}
export class World { export class World {
readonly city: City; readonly city: City;
readonly lngScale: number; readonly lngScale: number;
@@ -18,12 +97,8 @@ export class World {
readonly lngSquash: number; readonly lngSquash: number;
private readonly bboxes = new WeakMap<LatLng[], Float64Array>(); private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
private field: Float32Array | null = null; private state: Field | null = null;
private fieldLand: Uint8Array | null = null; private pending: Promise<boolean> | null = null;
private lats: Float64Array | null = null;
private lngs: Float64Array | null = null;
private latSteps = 0;
private lngSteps = 0;
constructor(city: City) { constructor(city: City) {
this.city = city; this.city = city;
@@ -152,6 +227,11 @@ export class World {
return this.pointInAny(lat, lng, this.city.landmasses); return this.pointInAny(lat, lng, this.city.landmasses);
} }
/** Inside one of the city's parks. The exact test; see `inParkSampled`. */
inPark(lat: number, lng: number): boolean {
return this.pointInAny(lat, lng, this.city.parks);
}
// ---- Relief ------------------------------------------------------------- // ---- Relief -------------------------------------------------------------
/** /**
@@ -205,55 +285,102 @@ export class World {
// ---- Cached heightfield ------------------------------------------------- // ---- Cached heightfield -------------------------------------------------
/** True once the heightfield exists and the samplers are cheap. */
get built(): boolean {
return this.state !== null;
}
/** /**
* `elevationAt` is not cheap every hill, four octaves of noise, and a * Build the heightfield, off the main thread if the browser will let us.
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of *
* thousands of lattice points, and then every building, road sample and * Resolves `true` when the field is up and the synchronous samplers are safe,
* camera target wants it again. Computed once, read back bilinearly. * `false` when the build was abandoned through `options.signal`. It never
* rejects and it never leaves a half-built field behind.
*
* Idempotent and single-flight: the second caller gets the first caller's
* promise, and the first caller's `signal` and `onProgress` are the ones that
* count. One `World` builds one field, once.
*
* The fallback to building here on the main thread is not a stub and is not
* optional. Workers are unavailable under `file://`, under a strict enough
* `Content-Security-Policy`, and in a handful of embedded webviews, and this
* repo's one enforced promise is that it boots with no server, no key and no
* account. A map that renders in two seconds is a slow map; a map that throws
* because `new Worker` was blocked is a broken one.
*/ */
private buildField(): { height: Float32Array; land: Uint8Array } { ready(options: ReadyOptions = {}): Promise<boolean> {
if (this.field && this.fieldLand && this.lats && this.lngs) { if (this.state) return Promise.resolve(true);
return { height: this.field, land: this.fieldLand }; if (this.pending) return this.pending;
const run = this.build(options).then((ok) => {
// Cleared when the build was abandoned, so a caller that still wants this
// `World` can start another; on success it stays set and never matters,
// because `this.state` short-circuits above.
if (!ok) this.pending = null;
return ok;
});
this.pending = run;
return run;
} }
const { bounds, cellLat, cellLng } = this.city;
const coarse = Math.max(1, this.city.coarseFactor ?? 1);
const regions = this.city.focusRegions ?? [];
// Rectilinear but NOT uniform: fine spacing across any band that a focus private async build(options: ReadyOptions): Promise<boolean> {
// region occupies, coarse everywhere else. See `buildAxis`. const { signal, onProgress } = options;
this.lats = buildAxis( if (signal?.aborted) return false;
bounds.minLat,
bounds.maxLat,
cellLat,
cellLat * coarse,
regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
);
this.lngs = buildAxis(
bounds.minLng,
bounds.maxLng,
cellLng,
cellLng * coarse,
regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
);
this.latSteps = this.lats.length - 1; const worker = spawnFieldWorker();
this.lngSteps = this.lngs.length - 1; if (worker) {
const w = this.lngSteps + 1; const result = await runInWorker(worker, this.city, signal, onProgress);
const height = new Float32Array((this.latSteps + 1) * w); if (result === "abandoned") return false;
const land = new Uint8Array((this.latSteps + 1) * w); if (result !== "failed") {
for (let i = 0; i <= this.latSteps; i++) { this.adopt(result);
const lat = this.lats[i] as number; return true;
for (let j = 0; j <= this.lngSteps; j++) {
const lng = this.lngs[j] as number;
const k = i * w + j;
const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
height[k] = onLand ? this.elevationAt(lat, lng) : 0;
} }
} }
this.field = height;
this.fieldLand = land; if (signal?.aborted) return false;
return { height, land }; // Let the caller's progress line reach the glass before we take the thread
// away for the better part of a second. This is the same double-rAF trick
// `main.ts` uses around its boot card, and for the same reason: a style
// change and the work that follows it in the same task paint together, so
// the label the user was supposed to read arrives after the freeze it was
// meant to explain.
onProgress?.({ phase: "heightfield", fraction: 0, onMainThread: true });
await nextPaint();
if (signal?.aborted) return false;
this.adopt(computeField(this));
onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: true });
return true;
}
/**
* Install a field, first one wins.
*
* `lattice()` hands its typed arrays straight out and `terrain.ts` keeps the
* reference, so replacing a field that is already in use would leave the mesh
* reading one lattice and the minimap another. The two would in fact agree
* the build is deterministic which is exactly what makes the bug the kind
* you find six months later.
*/
private adopt(field: Field): void {
if (!this.state) this.state = field;
}
/**
* The field, building it here and now if nobody awaited `ready()`.
*
* Sampling before ready is a bug in the caller, and this deliberately does
* not throw for it. The whole point of the Worker is to stop the main thread
* freezing; a thrown error would stop the map existing, which is a strictly
* worse failure and one that a self-hoster would hit on the very path no
* Worker available that the fallback exists to cover. So it warns once,
* loudly enough to find in a console, and builds.
*/
private ensureField(): Field {
if (this.state) return this.state;
warnSampledEarly(this.city.id, this.pending !== null);
const field = computeField(this);
this.adopt(field);
return field;
} }
/** /**
@@ -263,31 +390,14 @@ export class World {
* spacing is no longer uniform and a consumer cannot recover it from * spacing is no longer uniform and a consumer cannot recover it from
* `minLat + i * cellLat` any more. * `minLat + i * cellLat` any more.
*/ */
lattice(): { lattice(): Field {
latSteps: number; return this.ensureField();
lngSteps: number;
lats: Float64Array;
lngs: Float64Array;
height: Float32Array;
land: Uint8Array;
} {
const { height, land } = this.buildField();
return {
latSteps: this.latSteps,
lngSteps: this.lngSteps,
lats: this.lats as Float64Array,
lngs: this.lngs as Float64Array,
height,
land,
};
} }
/** Elevation in metres, bilinearly sampled from the cached lattice. */ /** Elevation in metres, bilinearly sampled from the cached lattice. */
elevationSampled(lat: number, lng: number): number { elevationSampled(lat: number, lng: number): number {
const { height } = this.buildField(); const { lats, lngs, lngSteps, height } = this.ensureField();
const lats = this.lats as Float64Array; const w = lngSteps + 1;
const lngs = this.lngs as Float64Array;
const w = this.lngSteps + 1;
const i = cellIndex(lats, lat); const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng); const j = cellIndex(lngs, lng);
@@ -311,6 +421,291 @@ export class World {
groundAt(lat: number, lng: number): number { groundAt(lat: number, lng: number): number {
return this.metres(this.elevationSampled(lat, lng)); return this.metres(this.elevationSampled(lat, lng));
} }
/**
* A yes/no mask read back off the lattice, with the exact polygon test run
* only where the lattice cannot answer.
*
* This is the boolean half of what `elevationSampled` already does for
* height, and it exists for the same measured reason. `blocks.ts` asks about
* ~186k candidate lots on the Bay Area board, each of which walked every edge
* of every landmass and every park: 240 ms of the boot's main thread, on a
* desktop, for two facts the Worker had already established across the whole
* lattice. Sampling instead costs two binary searches and four byte loads
* 19 ms for the same 186k lots, measured.
*
* The rule is **unanimity, or ask properly**. Four corners that agree decide
* the cell; a cell that straddles an edge falls through to `exact`, so the
* coastline and the park boundaries are answered by the polygons that define
* them and nothing is quantised where quantising would show. On the Bay Area
* that fallback fires for 532 of 186k lots, and the placement it produces
* differs from the exhaustive answer by eight buildings in 185,036.
*
* Unanimity is also the *more* correct answer inside a cell, not a
* concession. `terrain.ts` already emits a quad only where all four corners
* are land, so a lot that the exhaustive test called land inside a cell the
* terrain skipped was a building standing on no ground at all. This makes the
* two agree by construction.
*/
private sampled(
pick: (field: Field) => Uint8Array,
lat: number,
lng: number,
exact: (lat: number, lng: number) => boolean,
): boolean {
const field = this.ensureField();
const { lats, lngs, lngSteps } = field;
const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng);
// Off the board entirely. The lattice has no opinion and the polygons do.
if (i < 0 || j < 0) return exact.call(this, lat, lng);
const mask = pick(field);
const w = lngSteps + 1;
const k = i * w + j;
const votes = (mask[k] ?? 0) + (mask[k + 1] ?? 0) + (mask[k + w] ?? 0) + (mask[k + w + 1] ?? 0);
if (votes === 4) return true;
if (votes === 0) return false;
return exact.call(this, lat, lng);
}
/** `isLand`, read off the lattice. See `sampled` for what that costs and buys. */
isLandSampled(lat: number, lng: number): boolean {
return this.sampled(landOf, lat, lng, this.isLand);
}
/** `inPark`, read off the lattice. See `sampled`. */
inParkSampled(lat: number, lng: number): boolean {
return this.sampled(parkOf, lat, lng, this.inPark);
}
}
// Module-level so `sampled`'s two callers pass one stable function each rather
// than allocating a closure per lookup, which at 186k lookups per board is the
// difference between this optimisation and a different kind of garbage.
const landOf = (field: Field): Uint8Array => field.land;
const parkOf = (field: Field): Uint8Array => field.park;
// ---- Producing a field -----------------------------------------------------
/**
* The heightfield, from scratch. The expensive thing this whole module is
* arranged around.
*
* `elevationAt` is not cheap every hill, four octaves of noise, and a
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
* thousands of lattice points, and then every building, road sample and camera
* target wants it again. Computed once, read back bilinearly.
*
* A free function taking a `World` rather than a method, because the Worker
* runs exactly this code against a `World` it built from the cloned `City`.
* Sharing the function is what stops the off-thread and on-thread paths drifting
* into two subtly different maps and they would drift, because nobody looks at
* the fallback.
*
* `onRow` fires once per lattice row and must be cheap; the Worker uses it to
* throttle its progress messages, and the main-thread fallback ignores it,
* since nothing can observe progress on a thread it is blocking.
*/
export function computeField(world: World, onRow?: (done: number, rows: number) => void): Field {
const { bounds, cellLat, cellLng } = world.city;
const coarse = Math.max(1, world.city.coarseFactor ?? 1);
const regions = world.city.focusRegions ?? [];
// Rectilinear but NOT uniform: fine spacing across any band that a focus
// region occupies, coarse everywhere else. See `buildAxis`.
const lats = buildAxis(
bounds.minLat,
bounds.maxLat,
cellLat,
cellLat * coarse,
regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
);
const lngs = buildAxis(
bounds.minLng,
bounds.maxLng,
cellLng,
cellLng * coarse,
regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
);
const latSteps = lats.length - 1;
const lngSteps = lngs.length - 1;
const w = lngSteps + 1;
const rows = latSteps + 1;
const height = new Float32Array(rows * w);
const land = new Uint8Array(rows * w);
const park = new Uint8Array(rows * w);
for (let i = 0; i < rows; i++) {
const lat = lats[i] as number;
for (let j = 0; j <= lngSteps; j++) {
const lng = lngs[j] as number;
const k = i * w + j;
const onLand = world.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
height[k] = onLand ? world.elevationAt(lat, lng) : 0;
// Only on land, and not merely as an optimisation: a park mask with 1s
// out in the bay would let `sampled` carry a coastal cell unanimously
// into a park that stops at the shore.
park[k] = onLand && world.inPark(lat, lng) ? 1 : 0;
}
onRow?.(i + 1, rows);
}
return { latSteps, lngSteps, lats, lngs, height, land, park };
}
// ---- The Worker ------------------------------------------------------------
/** What `terrain.worker.ts` sends back. Kept here so both ends see one type. */
export type FieldMessage =
| { type: "progress"; done: number; rows: number }
| ({ type: "field" } & Field)
| { type: "failed"; message: string };
/** What it is sent. */
export interface FieldRequest {
city: City;
}
/**
* `new Worker(new URL(...), { type: "module" })` is spelled out inline because
* that literal form is what Vite pattern-matches to emit the worker chunk. A
* variable holding the URL builds clean and 404s in production.
*/
function spawnFieldWorker(): Worker | null {
if (typeof Worker === "undefined") return null;
try {
return new Worker(new URL("./terrain.worker.ts", import.meta.url), { type: "module" });
} catch {
// `file://` and some CSPs throw here rather than firing `onerror`.
return null;
}
}
/**
* Longest silence tolerated from a worker before it is written off.
*
* Not a build budget the worker reports progress about eight times a second,
* so on a slow phone taking twelve seconds over SoCal this never comes close to
* firing. It is a liveness check, and it exists for the one failure the Worker
* API gives you no event for: a browser reclaiming a worker under memory
* pressure. No `error`, no `messageerror`, nothing. Without this, the boot card
* stays up forever and the map never arrives, which is precisely the outcome
* the fallback path is supposed to make impossible.
*/
const WORKER_SILENCE_MS = 10_000;
/**
* Drive one worker to completion, or give up on it.
*
* Resolves rather than rejects in every case, including the ones that are
* genuinely wrong, because the caller's answer to all of them is the same: fall
* back and carry on. What differs is how loud we are about it on the way past.
*/
function runInWorker(
worker: Worker,
city: City,
signal: AbortSignal | undefined,
onProgress: ((progress: FieldProgress) => void) | undefined,
): Promise<Field | "failed" | "abandoned"> {
return new Promise((resolve) => {
let settled = false;
// `ReturnType` rather than `number`: this repo has `@types/node` in the
// tree for the server workspace, which makes the global `setTimeout` the
// Node one at type-check time even in browser code.
let watchdog: ReturnType<typeof setTimeout> | undefined;
const finish = (result: Field | "failed" | "abandoned") => {
if (settled) return;
settled = true;
clearTimeout(watchdog);
signal?.removeEventListener("abort", abandon);
// Terminate rather than let it finish and ignore the answer. An abandoned
// Bay Area build is most of a second of a core that the city being
// switched *to* wants for itself.
worker.terminate();
resolve(result);
};
const abandon = () => finish("abandoned");
signal?.addEventListener("abort", abandon, { once: true });
const heard = () => {
clearTimeout(watchdog);
watchdog = setTimeout(() => {
console.warn(
`Tera: heightfield worker went silent for ${WORKER_SILENCE_MS} ms; ` +
`building on the main thread`,
);
finish("failed");
}, WORKER_SILENCE_MS);
};
heard();
worker.onmessage = (event: MessageEvent<FieldMessage>) => {
heard();
const message = event.data;
if (message.type === "progress") {
onProgress?.({
phase: "heightfield",
fraction: message.rows > 0 ? message.done / message.rows : 0,
onMainThread: false,
});
return;
}
if (message.type === "field") {
onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: false });
const { latSteps, lngSteps, lats, lngs, height, land, park } = message;
finish({ latSteps, lngSteps, lats, lngs, height, land, park });
return;
}
console.warn(
`Tera: heightfield worker failed (${message.message}); building on the main thread`,
);
finish("failed");
};
// Fires for a module that will not load at all — a CSP that permits
// `worker-src` but not the script, an offline reload against a stale cache
// — as well as for anything thrown inside it.
worker.onerror = () => {
console.warn("Tera: heightfield worker did not start; building on the main thread");
finish("failed");
};
worker.onmessageerror = () => finish("failed");
try {
worker.postMessage({ city } satisfies FieldRequest);
} catch (err) {
// A `City` is pure data by contract — see `types.ts` — and structured
// clone is how that contract is enforced at runtime. If this throws,
// something has put a function, a class instance or a DOM node in a city
// pack, and the fix is to take it back out, not to JSON round-trip it
// here and lose whatever it was.
console.error(
`Tera: city "${city.id}" is not structured-cloneable, so its heightfield ` +
`cannot be built off the main thread. A city pack must be pure data.`,
err,
);
finish("failed");
}
});
}
/** Two frames, which is the shortest wait that straddles a paint. */
function nextPaint(): Promise<void> {
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
let warnedEarly = false;
function warnSampledEarly(cityId: string, building: boolean): void {
if (warnedEarly) return;
warnedEarly = true;
console.warn(
`Tera: the heightfield for "${cityId}" was sampled before \`await world.ready()\`` +
(building ? " and while a worker was already building it" : "") +
`, so it was built on the main thread instead. This is a bug in the caller.`,
);
} }
// ---- Variable-resolution lattice ------------------------------------------ // ---- Variable-resolution lattice ------------------------------------------
+821 -105
View File
File diff suppressed because it is too large Load Diff
+1830
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
/**
* `src/tools/` the instruments. Everything in here is for the person building
* this map, not for the person looking at it.
*
* There is one rule and this file exists to hold it: **nothing under
* `src/tools/` may be reached by a static import from the app.** `main.ts` loads
* it like this, and only like this:
*
* ```ts
* if (access.can.debug) {
* const { createGodmode } = await import("./tools/index.ts");
* godmode = createGodmode({ ... });
* }
* ```
*
* A static `import` would put the panel in the entry chunk, and the entry chunk
* is 772 kB before anybody has done anything the whole thing arrives, parses
* and runs for every anonymous visitor who will never see a single control in
* it. Behind a dynamic import Vite gives the tools a chunk of their own, and a
* visitor who is not god does not download the code at all. That is also the
* strongest available reading of "nothing here may run for a non-god visitor":
* not a hidden panel, not a disabled panel, no panel.
*
* The corollary is that the arrow points one way. A tool may import from
* `engine/`, `access.ts` or `adapters/`; nothing outside `src/tools/` may import
* from inside it except through an `await import()`, or the split silently stops
* happening and nobody notices until the bundle is measured again.
*
* Type-only imports are the exception and are free: `import type { Godmode }` is
* erased at build time and creates no chunk edge. `main.ts` needs one to hold
* the handle in a nullable field.
*/
export { createGodmode } from "./godmode.ts";
export type { Godmode, GodmodeOptions, GodmodePlace } from "./godmode.ts";
+958
View File
@@ -0,0 +1,958 @@
/**
* Fly the camera somewhere, like what you see, and get back the five numbers
* that put it there.
*
* Adding a city to this repo means hand-authoring `chapters: Chapter[]`, and
* every chapter carries a `focus` `{ lat, lng, distance, height, rotation }`
* which is only knowable by flying somewhere, liking the frame, and then
* working out what the numbers were. The loop that produced the twelve poses in
* `cities/sf.ts` was: guess five numbers, rebuild, look, guess again. New York
* is on the roadmap and it needs a dozen more of them.
*
* ## This is the inverse of `chapterPose`, and the round trip is measured
*
* `scene.ts` turns a focus into a camera with
*
* target = (x, groundAt(lat,lng), z) where [x,z] = project(lat,lng)
* position = target + (sin(rot)·distance, height, cos(rot)·distance)
*
* which is invertible in one direction and *not* in the other, and the part
* that is not is the whole reason this file measures itself. Going backwards:
* `lat`/`lng` come from `unproject` of the orbit target's x and z, `distance`
* and `rotation` are the polar form of the horizontal offset from target to
* camera, and `height` is the camera's y above the ground under the target. The
* camera position comes back exactly. The *target* does not, because
* `chapterPose` pins target.y to the ground and OrbitControls does not: pan the
* view and the target lifts off the terrain, and no chapter can express that.
* The panel therefore shows the lift and the aim error it causes rather than
* quietly emitting a pose that frames something else. See `measure`.
*
* ## The maths is duplicated from `scene.ts` on purpose, and it is a liability
*
* `chapterPose` is a closure inside `createScene` and is not exported, so
* `poseOf` below is a copy of it. That is the one thing in this file that can
* rot silently: change the pose convention in `scene.ts` and this tool will go
* on confidently emitting the old one. The fix is for `scene.ts` to export the
* conversion and for this file to import it; until then, the two blocks are
* written to look identical so a diff between them is obvious.
*
* ## Precision: five decimals of degree, two of unit, five of radian
*
* The point of the tool is a block you can paste, so the numbers have to be
* short enough to read and long enough to reproduce the frame. Measured over
* every authored chapter in both packs plus eight jittered poses around each,
* worst case:
*
* - **What the packs carry today** (4 dp of degree, integer distance and
* height, 2 dp of radian, all typed by hand from a map): the camera lands
* 265 m from where it was on the Bay Area board and 466 m out on SoCal, and
* the view direction is 0.7°1.2° off about thirty pixels across a
* 1600-pixel frame. Fine for a pose a human invented at those digits;
* useless for reproducing one a human found by flying.
* - **This scheme**: camera position within 0.0066 scene units (0.62 m) on
* the Bay Area and 0.0063 units (2.44 m) on SoCal, orbit target within
* 0.74 m and 0.89 m, and the aim within 0.0098° 0.024% of a 42° frame,
* which is a third of a pixel at 1600 wide.
*
* Going finer buys nothing anyone can see and costs a digit in a file people
* read. Trailing zeros are trimmed, so a pose that happens to be round emits
* `rotation: 0.4`, exactly as `sf.ts` already has it.
*
* **The order of the quantisation is load-bearing.** Round `lat`/`lng` *first*,
* then solve `distance`, `height` and `rotation` against the ground under the
* rounded point. The obvious order take all five numbers off the live camera,
* round all five feeds the terrain's own slope into the camera: `height` is
* measured from the ground under the exact target and re-applied over the
* ground under the rounded one, and with `verticalExaggeration` at 3.6 a metre
* of horizontal rounding on the side of Twin Peaks is several units of altitude.
* Measured, that order costs 0.98 m instead of 0.62 m at these digits, and
* 7.8 m instead of 3.0 m at the packs' four decimals.
*
* ## Shape
*
* Same self-contained imperative handle as `engine/minimap.ts`, and the same
* two rules: the caller supplies a container and owns where it goes, and
* `tick()` runs inside a frame loop so it compares a timestamp and eight
* numbers and returns. Unlike the minimap it never writes to the camera at all
* the only way this tool moves anything is by handing a `Pose` to the kit's
* own `flyTo`.
*
* God tier only. `access.ts` is clear that a browser-side check is theatre
* against anyone with a console, so the gate here is not security; it is a
* loaded gun pointed away from the ninety-nine percent of sessions that have no
* business seeing an authoring instrument at all.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { Pose } from "../engine/scenekit.ts";
import type { Chapter } from "../engine/types.ts";
import type { World } from "../engine/world.ts";
export interface PoseEditorOptions {
/**
* Where the panel mounts. The tool appends exactly one element to it and
* owns everything below that; the caller owns the box, its position and its
* size.
*/
container: HTMLElement;
/**
* Pass `access.can.debug`. Construction throws when it is false see the
* file header for why that is a thrown programming error and not a hidden
* no-op.
*/
allowed: boolean;
/** The same `World` the scene is drawing. `groundAt` must agree, or nothing does. */
world: World;
/** The live scene camera. Read every tick, written never. */
camera: THREE.PerspectiveCamera;
/** The live orbit controls. `controls.target` is the pose's target. */
controls: OrbitControls;
/**
* The kit's flight, `SceneKit.flyTo`. The only channel through which this
* tool is allowed to move the camera, and the reason it takes a callback
* rather than the controls: a tool that wrote `camera.position` directly
* would be a second thing with opinions about where the camera is, which is
* the failure CONTRACT.md §1 splits `Stage` and `SceneKit` to avoid.
*/
flyTo(pose: Pose): void;
/**
* `SceneKit.flying`. Capture is refused mid-flight: a frame sampled halfway
* through an eased interpolation is a pose nobody chose, and it looks
* plausible enough in the readout to get pasted.
*/
flying?(): boolean;
/**
* The chapters already in the pack, for the starting number and for the
* duplicate-id warning. `chapterById` in `scene.ts` is built from an object
* literal, so two chapters sharing an id means one of them silently is not
* in the tour.
*/
existingChapters?: readonly Chapter[];
}
/** How far the emitted block lands from the camera it was taken off. */
export interface PoseResidual {
/** Camera position error, in scene units. */
position: number;
/** Orbit target error, in scene units. Carries the ground lock as well as rounding. */
target: number;
/** Angle between the live view direction and the emitted one, in degrees. */
aim: number;
/** `aim` as a fraction of the camera's vertical field of view. */
frame: number;
/**
* How far the live orbit target floats above the terrain under it, in scene
* units. Measured against the ground under the *unrounded* target, so it is
* a statement about the camera and not about the emission precision: it is
* non-zero exactly when the view has been panned, and that is the one part
* of a pose a `Chapter` cannot carry.
*/
lift: number;
}
export interface CapturedPose {
chapter: Chapter;
residual: PoseResidual;
}
export interface PoseEditor {
/** Read the live camera, append a chapter to the session list, select it. */
capture(): CapturedPose | null;
poses(): readonly CapturedPose[];
/** The whole list as a paste-ready `chapters` array. */
code(): string;
/** Call from the frame loop. Cheap by construction; see `tick`. */
tick(): void;
setVisible(visible: boolean): void;
destroy(): void;
}
// ---- Precision --------------------------------------------------------------
const LATLNG_DP = 5;
const SPAN_DP = 2;
const ROT_DP = 5;
const TAU = Math.PI * 2;
/** Where the packs wrap. Not enforced by a formatter in this repo; matched by eye. */
const COLUMNS = 100;
/** Live readout ceiling. The stage runs at 60 and none of these digits need it. */
const FRAME_MS = 120;
/**
* Aim error, in degrees, above which the panel stops calling a pose clean.
* A tenth of a degree is four pixels across a 1600-pixel frame under it
* nothing on screen moves, over it the pasted chapter is framing something
* slightly different from what was approved.
*/
const AIM_WARN = 0.1;
export function createPoseEditor(options: PoseEditorOptions): PoseEditor {
if (!options.allowed) {
throw new Error("poseEditor is a god-tier instrument and was constructed without the tier");
}
const { world, camera, controls } = options;
const existing = options.existingChapters ?? [];
// ---- The conversion -------------------------------------------------------
/**
* A copy of `chapterPose` in `engine/scene.ts`. Kept character-for-character
* where it can be, so that a diff between the two files reads as a diff. See
* the file header: this duplication is the one thing here that can rot.
*
* It writes into scratch vectors rather than allocating, because `measure`
* calls it from the frame loop. `SceneKit.flyTo` copies out of the pose it is
* given, so handing it the scratch is safe and if that ever stops being
* true this is where it breaks.
*/
const scratch: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
function poseOf(focus: Chapter["focus"], into: Pose = scratch): Pose {
const [x, z] = world.project(focus.lat, focus.lng);
const groundY = world.groundAt(focus.lat, focus.lng);
into.target.set(x, groundY, z);
into.position.set(
x + Math.sin(focus.rotation) * focus.distance,
groundY + focus.height,
z + Math.cos(focus.rotation) * focus.distance,
);
return into;
}
/**
* The live camera, as a `Chapter["focus"]`, already at emission precision.
*
* Quantised in the order the file header argues for: the target first,
* everything else against where the target landed. The bearing convention is
* `chapterPose`'s `atan2(dx, dz)`, so zero is due south of the target and
* the angle opens toward the east, which is not the compass bearing anyone
* expects and is what the packs are already written in.
*/
function readFocus(): Chapter["focus"] {
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
const lat = round(rawLat, LATLNG_DP);
const lng = round(rawLng, LATLNG_DP);
const [x, z] = world.project(lat, lng);
const groundY = world.groundAt(lat, lng);
const dx = camera.position.x - x;
const dz = camera.position.z - z;
const distance = Math.hypot(dx, dz);
// Directly overhead the bearing is undefined, and `atan2` does not say so:
// on a pair of negative zeros it answers -π, which the wrap below turns
// into a confident π. The pose still round-trips either way — sin and cos
// of anything times a zero distance is a zero offset — but
// `rotation: 3.14159, distance: 0` in a city pack is a riddle, so straight
// down is written as zero.
let rotation = distance < 1e-6 ? 0 : Math.atan2(dx, dz);
if (rotation < 0) rotation += TAU;
rotation = round(rotation, ROT_DP);
// Rounding up through a full turn: a bearing a hair below due south comes
// out of the wrap as 6.283185…, which at five decimals is 6.28319, which is
// larger than a turn. Zero is the same pose and reads like one.
if (rotation >= TAU) rotation = 0;
return {
lat,
lng,
distance: round(distance, SPAN_DP),
height: round(camera.position.y - groundY, SPAN_DP),
rotation,
};
}
const probe: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
const liveDir = new THREE.Vector3();
const emitDir = new THREE.Vector3();
/**
* What the emitted block costs, against the camera it was taken off.
*
* This is the correctness claim of the whole tool and it is checked at
* capture rather than asserted in a comment: the focus goes back through the
* local copy of `chapterPose` and the two poses are differenced. Three
* separate numbers because they fail separately rounding moves the camera,
* the ground lock moves the target, and only the angle between the two view
* vectors says whether any of it is visible.
*/
function measure(focus: Chapter["focus"]): PoseResidual {
poseOf(focus, probe);
liveDir.subVectors(controls.target, camera.position);
emitDir.subVectors(probe.target, probe.position);
const degrees =
liveDir.lengthSq() > 0 && emitDir.lengthSq() > 0
? (liveDir.angleTo(emitDir) * 180) / Math.PI
: 0;
// Against the ground under the live target rather than under the rounded
// one. The difference is sub-millimetre and it is still worth the second
// sample: `probe.target.y` folds the terrain's slope across a metre of
// rounding into a number the panel presents as "you panned", and a warning
// that fires on its own rounding is a warning people learn to ignore.
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
return {
position: probe.position.distanceTo(camera.position),
target: probe.target.distanceTo(controls.target),
aim: degrees,
frame: camera.fov > 0 ? degrees / camera.fov : 0,
lift: controls.target.y - world.groundAt(rawLat, rawLng),
};
}
// ---- The session list -----------------------------------------------------
interface Entry {
chapter: Chapter;
residual: PoseResidual;
/** False once the id has been typed, so a later label edit stops overwriting it. */
idAuto: boolean;
row: HTMLElement;
name: HTMLElement;
note: HTMLElement;
}
const entries: Entry[] = [];
let selected: Entry | null = null;
let visible = true;
let destroyed = false;
// ---- DOM ------------------------------------------------------------------
/**
* The panel lives in a shadow root.
*
* `index.html` is the entire stylesheet of this application and it belongs to
* whoever is integrating this tool, not to the tool. A shadow root is the
* only way to ship a widget with its own styling that neither reads from nor
* writes to that file. Custom properties still cross the boundary, which is
* the useful half of the isolation: `var(--amber)` below picks up the app's
* own accent when the panel is mounted inside it and falls back to the same
* literal when it is mounted anywhere else.
*/
const host = document.createElement("div");
host.className = "tera-pose-editor";
const root = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = CSS;
root.append(style);
const panel = el("div", "panel");
root.append(panel);
panel.append(el("div", "hd", "Chapter pose"));
// The live readout.
const live = el("div", "live");
const liveLatLng = el("div", "row mono");
const liveFocus = el("div", "row mono");
const liveMeta = el("div", "row sub");
const liveWarn = el("div", "warn");
liveWarn.hidden = true;
live.append(liveLatLng, liveFocus, liveMeta, liveWarn);
panel.append(live);
const captureBtn = el("button", "btn primary", "Capture pose") as HTMLButtonElement;
captureBtn.type = "button";
captureBtn.addEventListener("click", () => {
capture();
});
panel.append(captureBtn);
// The naming form. It edits whichever pose is selected rather than being a
// form you fill in before capturing: you find the frame first and work out
// what to call it second, which is the order the job actually happens in.
const form = el("div", "form");
const fId = field(form, "id", "kebab-case, unique in the pack");
const fNumber = field(form, "number", '"13"');
const fLabel = field(form, "label", "Mission Bay");
const fShort = field(form, "shortLabel", "Mission Bay");
const fDesc = area(form, "description", "A sentence about why it is on the map.");
panel.append(form);
const list = el("div", "list");
panel.append(list);
const actions = el("div", "actions");
const copyOne = el("button", "btn", "Copy chapter") as HTMLButtonElement;
const copyAll = el("button", "btn", "Copy all") as HTMLButtonElement;
copyOne.type = "button";
copyAll.type = "button";
copyOne.addEventListener("click", () => {
if (selected) void copy(emitChapter(selected.chapter));
});
copyAll.addEventListener("click", () => {
if (entries.length > 0) void copy(code());
});
actions.append(copyOne, copyAll);
panel.append(actions);
const msg = el("div", "msg");
panel.append(msg);
/**
* The clipboard fallback.
*
* `navigator.clipboard` needs a secure context, and a self-hoster running
* this off `http://` on a LAN address has none which is a documented
* deployment in `deploy/STATIC.md`, not an edge case. So the failure path is
* a textarea with the text already selected, and the user presses their own
* copy key.
*
* `document.execCommand("copy")` is deliberately not tried in between. Inside
* a shadow root the selection it copies is not reliably the one you just
* made, and it reports success either way; a button that says "Copied" and
* copied nothing is worse than a button that says it could not.
*/
const out = el("textarea", "out") as HTMLTextAreaElement;
out.readOnly = true;
out.spellcheck = false;
out.hidden = true;
panel.append(out);
/**
* Keystrokes stop at the shadow boundary.
*
* `main.ts` binds the application's shortcuts to `window` and guards them by
* testing whether `event.target` is an input or a textarea. **That guard does
* not work through a shadow root**: the event is retargeted on its way out, so
* by the time it reaches `window` the target is this host `<div>` and the
* guard passes. Typing "Mission Bay" into the label field would fly the camera
* to chapter one on the "1", toggle the plan view on the "m" and open an
* office on the "o" which is to say a tool whose entire premise is that it
* does not perturb the scene would be the only thing in the app that did.
*
* Everything is swallowed rather than only the keys that currently mean
* something, because the alternative is this list going stale the first time
* somebody adds a shortcut. Escape is the one that also does something here:
* it puts the clipboard fallback away.
*/
function containKeys(event: Event) {
event.stopPropagation();
if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
if (out.hidden) return;
out.hidden = true;
say("");
}
root.addEventListener("keydown", containKeys);
root.addEventListener("keyup", containKeys);
options.container.append(host);
// ---- Form wiring ----------------------------------------------------------
function bindField(input: HTMLInputElement | HTMLTextAreaElement, apply: (v: string) => void) {
input.addEventListener("input", () => {
if (!selected) return;
apply(input.value);
refreshRow(selected);
warnDuplicate();
});
}
bindField(fId, (v) => {
if (!selected) return;
selected.chapter.id = v;
// Typing an id takes it off the leash; a later label edit stops rewriting
// it. Clearing the field puts it back, which is the only way to undo that
// without a reset button nobody would find.
selected.idAuto = v.trim() === "";
if (selected.idAuto) selected.chapter.id = slug(selected.chapter.label);
});
bindField(fNumber, (v) => {
if (selected) selected.chapter.number = v;
});
bindField(fLabel, (v) => {
if (!selected) return;
selected.chapter.label = v;
if (selected.idAuto) {
selected.chapter.id = slug(v);
fId.value = selected.chapter.id;
}
});
bindField(fShort, (v) => {
if (selected) selected.chapter.shortLabel = v;
});
bindField(fDesc, (v) => {
if (selected) selected.chapter.description = v;
});
// ---- Capture and the list -------------------------------------------------
function capture(): CapturedPose | null {
if (options.flying?.()) {
say("Still flying — wait for the camera to land.");
return null;
}
const focus = readFocus();
const residual = measure(focus);
const number = pad(existing.length + entries.length + 1);
const label = `Untitled ${number}`;
const chapter: Chapter = {
id: slug(label),
number,
label,
shortLabel: label,
focus,
description: "",
};
const row = el("div", "item");
const name = el("button", "name") as HTMLButtonElement;
name.type = "button";
const note = el("div", "res mono");
const fly = el("button", "ico", "fly") as HTMLButtonElement;
const drop = el("button", "ico", "×") as HTMLButtonElement;
fly.type = "button";
drop.type = "button";
fly.title = "Fly to the emitted pose — the rounded numbers, not the live camera";
drop.title = "Forget this pose";
const head = el("div", "item-head");
head.append(name, fly, drop);
row.append(head, note);
const entry: Entry = { chapter, residual, idAuto: true, row, name, note };
name.addEventListener("click", () => select(entry));
fly.addEventListener("click", () => {
// Through the kit's own flight, and to the *emitted* pose rather than the
// captured one, because the emitted pose is what the paste will produce
// and the whole point is to see it before trusting it.
options.flyTo(poseOf(entry.chapter.focus));
});
drop.addEventListener("click", () => remove(entry));
entries.push(entry);
list.append(row);
refreshRow(entry);
select(entry);
warnDuplicate();
return { chapter, residual };
}
function remove(entry: Entry) {
const at = entries.indexOf(entry);
if (at < 0) return;
entries.splice(at, 1);
entry.row.remove();
if (selected === entry) select(entries[Math.min(at, entries.length - 1)] ?? null);
warnDuplicate();
}
function select(entry: Entry | null) {
if (selected) selected.row.classList.remove("sel");
selected = entry;
if (entry) entry.row.classList.add("sel");
form.classList.toggle("off", entry === null);
copyOne.disabled = entry === null;
copyAll.disabled = entries.length === 0;
fId.value = entry?.chapter.id ?? "";
fNumber.value = entry?.chapter.number ?? "";
fLabel.value = entry?.chapter.label ?? "";
fShort.value = entry?.chapter.shortLabel ?? "";
fDesc.value = entry?.chapter.description ?? "";
}
function refreshRow(entry: Entry) {
const r = entry.residual;
entry.name.textContent = `${entry.chapter.number} · ${entry.chapter.label || "—"}`;
entry.note.textContent =
`pos ${r.position.toFixed(4)}u · aim ${r.aim.toFixed(4)}° · ` +
`${(r.frame * 100).toFixed(3)}% of frame`;
entry.note.classList.toggle("bad", r.aim > AIM_WARN);
}
/**
* Two chapters with the same id is not a lint, it is a missing chapter:
* `scene.ts` keys its flights off an object built from the list, so the
* second one wins and the first is unreachable from the legend.
*/
function warnDuplicate() {
const seen = new Set(existing.map((c) => c.id));
const clashes: string[] = [];
for (const e of entries) {
if (seen.has(e.chapter.id)) clashes.push(e.chapter.id);
seen.add(e.chapter.id);
}
if (clashes.length > 0) say(`Duplicate id: ${clashes.join(", ")}`, true);
else if (msg.classList.contains("bad")) say("");
}
function say(text: string, bad = false) {
msg.textContent = text;
msg.classList.toggle("bad", bad && text !== "");
}
async function copy(text: string) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
say("Copied.");
return;
} catch {
// Permission refused, or a context the API decided was not secure
// enough after all. Fall through to the textarea.
}
}
out.hidden = false;
out.value = text;
out.focus({ preventScroll: true });
out.select();
say("Clipboard unavailable — press Ctrl/Cmd-C.");
}
function code(): string {
return emitChapters(entries.map((e) => e.chapter));
}
// ---- The live readout -----------------------------------------------------
let lastDraw = 0;
// Compared exactly rather than with an epsilon, for the reason `minimap.ts`
// spells out: OrbitControls' damping asymptotes, and an epsilon freezes the
// readout a few frames before the camera has actually stopped.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
function moved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ
);
}
function paintLive() {
const focus = readFocus();
const r = measure(focus);
liveLatLng.textContent = `lat ${num(focus.lat)} lng ${num(focus.lng)}`;
liveFocus.textContent =
`dist ${num(focus.distance)} height ${num(focus.height)} rot ${num(focus.rotation)}`;
// The 3D standoff against the controls' own ceiling, because a pose outside
// it is one `controls.update()` away from being quietly reeled in — the
// trap `sf.ts` has a paragraph about above its regional chapters.
const radius = Math.hypot(focus.distance, focus.height);
const ceiling = controls.maxDistance;
const groundM = world.unitsToMetres(controls.target.y - r.lift);
liveMeta.textContent =
`ground ${groundM.toFixed(0)} m · standoff ${radius.toFixed(1)} of ${ceiling.toFixed(0)}`;
const tight = ceiling > 0 && radius > ceiling * 0.99;
if (Math.abs(r.lift) > 1e-4 && r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent =
`Target is ${r.lift.toFixed(2)}u off the ground — a chapter cannot carry that, ` +
`so the emitted pose aims ${r.aim.toFixed(2)}° elsewhere. Re-fly a chapter to reset it.`;
} else if (tight) {
liveWarn.hidden = false;
liveWarn.textContent = "At the orbit ceiling — the pose may be reeled in on arrival.";
} else if (r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent = `Round trip is ${r.aim.toFixed(3)}° out.`;
} else {
liveWarn.hidden = true;
}
}
function tick() {
if (destroyed || !visible) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
if (!moved()) return;
lastDraw = now;
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
paintLive();
}
select(null);
paintLive();
return {
capture,
poses: () => entries.map((e) => ({ chapter: e.chapter, residual: e.residual })),
code,
tick,
setVisible(next) {
visible = next;
host.hidden = !next;
// The readout is stale by however long the panel was shut, and `moved()`
// will say nothing changed if the camera happens to be back where it was.
if (next) paintLive();
},
destroy() {
if (destroyed) return;
destroyed = true;
root.removeEventListener("keydown", containKeys);
root.removeEventListener("keyup", containKeys);
// Everything else this file listens to is on a node inside `host`, so
// removing it takes the listeners with it. The entries hold DOM that is
// inside `host` too; dropping the array is what stops them being reachable.
host.remove();
entries.length = 0;
selected = null;
},
};
}
// ---- Emission ---------------------------------------------------------------
/**
* One chapter, in the shape `cities/sf.ts` already has: two-space indent, key
* order `id, number, label, shortLabel, focus, description`, and a trailing
* comma, because what you are pasting is an element of the `CHAPTERS` array
* rather than a standalone declaration.
*/
export function emitChapter(chapter: Chapter, indent = " "): string {
const inner = `${indent} `;
return [
`${indent}{`,
`${inner}id: ${quote(chapter.id)},`,
`${inner}number: ${quote(chapter.number)},`,
`${inner}label: ${quote(chapter.label)},`,
`${inner}shortLabel: ${quote(chapter.shortLabel)},`,
emitFocus(chapter.focus, inner),
emitDescription(chapter.description, inner),
`${indent}},`,
].join("\n");
}
/** The session list as the declaration a city pack ends with. */
export function emitChapters(chapters: readonly Chapter[]): string {
const body = chapters.map((c) => emitChapter(c)).join("\n");
return `export const CHAPTERS: City["chapters"] = [\n${body}\n];\n`;
}
/**
* The focus, on one line where it fits and one key per line where it does not.
*
* Every focus in both packs is on one line today, but they were typed at four
* decimals and integer distances; a captured pose at five and two runs to about
* 97 columns and a long one goes over. This is the only formatting rule in the
* file and it is the one the rest of the tree follows by eye there is no
* formatter in `devDependencies` to defer to.
*/
function emitFocus(focus: Chapter["focus"], indent: string): string {
const pairs = [
`lat: ${num(focus.lat)}`,
`lng: ${num(focus.lng)}`,
`distance: ${num(focus.distance)}`,
`height: ${num(focus.height)}`,
`rotation: ${num(focus.rotation)}`,
];
const flat = `${indent}focus: { ${pairs.join(", ")} },`;
if (flat.length <= COLUMNS) return flat;
return [`${indent}focus: {`, ...pairs.map((p) => `${indent} ${p},`), `${indent}},`].join("\n");
}
function emitDescription(description: string, indent: string): string {
// Collapsed to one line rather than escaped as `\n`. The field is a sentence
// in a legend; a textarea that has been typed into with the Enter key still
// means one paragraph, and a literal newline inside the quotes would not
// compile.
const text = quote(description.replace(/\s+/g, " ").trim());
const flat = `${indent}description: ${text},`;
if (flat.length <= COLUMNS) return flat;
return `${indent}description:\n${indent} ${text},`;
}
/**
* Already-rounded numbers, printed short. `String` drops the trailing zeros
* `toFixed` would leave, so a pose that lands on 0.4 emits `0.4` and matches
* what is in the packs; nothing here reaches the magnitude where JavaScript
* switches to exponent notation.
*/
function num(value: number): string {
return String(value === 0 ? 0 : value);
}
function quote(text: string): string {
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function round(value: number, dp: number): number {
return Number(value.toFixed(dp));
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function slug(label: string): string {
const s = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s === "" ? "chapter" : s;
}
// ---- DOM helpers ------------------------------------------------------------
function el(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function field(parent: HTMLElement, name: string, placeholder: string): HTMLInputElement {
const wrap = el("label", "f");
wrap.append(el("span", "k", name));
const input = document.createElement("input");
input.type = "text";
input.placeholder = placeholder;
input.spellcheck = false;
wrap.append(input);
parent.append(wrap);
return input;
}
function area(parent: HTMLElement, name: string, placeholder: string): HTMLTextAreaElement {
const wrap = el("label", "f col");
wrap.append(el("span", "k", name));
const input = document.createElement("textarea");
input.rows = 3;
input.placeholder = placeholder;
wrap.append(input);
parent.append(wrap);
return input;
}
/**
* The panel's stylesheet.
*
* Written against the application's custom properties with the literal as the
* fallback, so the tool looks like it belongs when it is mounted inside Tera
* and still looks deliberate when it is mounted in a bare page. `:host` sets no
* `all: initial` on purpose that would reset the custom properties along with
* everything else and the fallbacks would be all anyone ever saw.
*/
const CSS = `
:host { display: block; }
:host([hidden]) { display: none; }
* { box-sizing: border-box; }
.panel {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
line-height: 1.5;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
padding: var(--s3, 12px);
display: flex;
flex-direction: column;
gap: var(--s2, 8px);
}
.hd {
text-transform: uppercase;
letter-spacing: 0.09em;
font-size: 10px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.live {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2, 8px);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border-radius: var(--r-sm, 5px);
}
.row { white-space: pre; overflow-x: auto; }
.sub { color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.warn {
margin-top: var(--s1, 4px);
color: var(--amber-ink, #ffd68a);
white-space: normal;
}
.btn {
font: inherit;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: var(--s2, 8px);
cursor: pointer;
transition: background var(--t, 150ms ease);
}
.btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.primary { color: #14202b; background: var(--amber, #f2b134); border-color: transparent; }
.btn.primary:hover { background: var(--amber-lit, #ffc555); }
.form { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.form.off { opacity: 0.35; pointer-events: none; }
.f { display: flex; align-items: center; gap: var(--s2, 8px); }
.f.col { align-items: flex-start; }
.k {
flex: 0 0 74px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
padding-top: 3px;
}
input, textarea {
font: inherit;
flex: 1 1 auto;
min-width: 0;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 3px 6px;
resize: vertical;
}
input:focus, textarea:focus { outline: 1px solid var(--amber, #f2b134); }
.list { display: flex; flex-direction: column; gap: 2px; max-height: 34vh; overflow-y: auto; }
.item {
padding: var(--s1, 4px) var(--s2, 8px);
border-radius: var(--r-sm, 5px);
border: 1px solid transparent;
}
.item.sel {
border-color: var(--amber, #f2b134);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
}
.item-head { display: flex; align-items: center; gap: var(--s1, 4px); }
.name {
font: inherit;
flex: 1 1 auto;
min-width: 0;
text-align: left;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ico {
font: inherit;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
background: none;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 0 5px;
cursor: pointer;
}
.ico:hover { color: var(--amber-ink, #ffd68a); }
.res { color: var(--ink-4, rgba(255, 255, 255, 0.26)); font-size: 10px; }
.bad { color: var(--amber-ink, #ffd68a); }
.actions { display: flex; gap: var(--s2, 8px); }
.actions .btn { flex: 1 1 0; }
.msg { min-height: 1.5em; color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.out {
width: 100%;
height: 18vh;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.mono { font-variant-numeric: tabular-nums; }
`;