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:
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
provider's data is included or redistributed, and no client for a provider
|
||||
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.
|
||||
|
||||
@@ -35,7 +35,7 @@ Three tiers, resolved once at boot by `src/access.ts`:
|
||||
| the map, the plan view, the named chapters | ✅ | ✅ | ✅ |
|
||||
| the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth |
|
||||
| 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`
|
||||
says so at length. Live data and office presence are withheld by the *API*, from
|
||||
@@ -56,18 +56,28 @@ npm run dev
|
||||
|
||||
```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";
|
||||
|
||||
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,
|
||||
markerPalette: { hiring: 0x4ade80, closed: 0xef4444 },
|
||||
});
|
||||
|
||||
scene.setMarkers([
|
||||
scene?.setMarkers([
|
||||
{ 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
|
||||
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
|
||||
@@ -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/cities/ data packs — pure geography, no code
|
||||
src/adapters/ where outside data plugs in
|
||||
src/tools/ instruments — god-only, dynamically imported, never statically
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
+303
-54
@@ -2,7 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<!--
|
||||
An empty data URI, not an icon file. `src/` carries no binary assets by
|
||||
@@ -89,12 +98,29 @@
|
||||
what people noticed. */
|
||||
background: #0d1218;
|
||||
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;
|
||||
color: var(--ink);
|
||||
-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 ------------------------------------------------------
|
||||
Five sizes. 9px is reserved for uppercase micro-labels, where the caps
|
||||
@@ -162,22 +188,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
|
||||
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; }
|
||||
.scrub button {
|
||||
font: inherit;
|
||||
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); }
|
||||
/* The `#hour` scrubber and its `now` button used to live here. They were
|
||||
god-only, and the godmode panel now owns the same override with a date
|
||||
as well as an hour — two writers for one value, of which this was the
|
||||
one that silently threw the date away. `main.ts` says the rest. */
|
||||
|
||||
.cities { display: flex; gap: var(--s1); }
|
||||
.city {
|
||||
@@ -195,7 +209,9 @@
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
|
||||
@media (hover: hover) {
|
||||
.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); }
|
||||
|
||||
.enter {
|
||||
@@ -212,7 +228,7 @@
|
||||
box-shadow: var(--shadow);
|
||||
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
|
||||
explanation, not an action, and the one filled amber thing on screen
|
||||
@@ -255,7 +271,9 @@
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
|
||||
@media (hover: hover) {
|
||||
.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); }
|
||||
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -266,7 +284,7 @@
|
||||
.panel-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: var(--s3);
|
||||
top: calc(var(--s3) + env(safe-area-inset-top));
|
||||
left: var(--s3);
|
||||
z-index: 4;
|
||||
align-items: center;
|
||||
@@ -287,10 +305,18 @@
|
||||
}
|
||||
.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 {
|
||||
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);
|
||||
width: 15rem;
|
||||
display: flex;
|
||||
@@ -325,7 +351,12 @@
|
||||
}
|
||||
|
||||
.tier {
|
||||
position: fixed;
|
||||
top: calc(var(--s4) + env(safe-area-inset-top));
|
||||
right: var(--s4);
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
max-width: min(15rem, 50vw);
|
||||
padding: var(--s2) var(--s3);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -336,7 +367,15 @@
|
||||
text-transform: uppercase;
|
||||
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 a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
|
||||
|
||||
@@ -344,7 +383,10 @@
|
||||
.rail {
|
||||
position: fixed;
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -356,7 +398,32 @@
|
||||
second panel. */
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -377,6 +444,19 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
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 {
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
@@ -389,18 +469,21 @@
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
|
||||
@media (hover: hover) {
|
||||
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
|
||||
}
|
||||
|
||||
/* ---- 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
|
||||
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
|
||||
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 {
|
||||
position: fixed;
|
||||
left: var(--s4);
|
||||
bottom: var(--s4);
|
||||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
max-width: calc(100vw - var(--s4) * 2);
|
||||
@@ -417,6 +500,18 @@
|
||||
}
|
||||
.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 --------------------------------------------------------- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
@@ -438,6 +533,8 @@
|
||||
}
|
||||
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
|
||||
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);
|
||||
align-items: baseline; font-size: 11px; }
|
||||
.keys dt { text-align: right; }
|
||||
@@ -454,7 +551,7 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
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 -------------------------------------------------------------
|
||||
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);
|
||||
min-height: 1.4em; }
|
||||
.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 -------------------------------------------------------
|
||||
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
|
||||
map at all and lives behind `M`. `main.ts` owns the *state* of both, so
|
||||
these rules only describe what each state looks like. */
|
||||
map at all and moves to the "plan view" button on the rail (and `M`,
|
||||
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) {
|
||||
.panel-toggle { display: flex; }
|
||||
#panel {
|
||||
@@ -523,23 +630,131 @@
|
||||
opacity: 0;
|
||||
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; }
|
||||
.rail { right: var(--s3); bottom: var(--s3); }
|
||||
.source { left: var(--s3); bottom: var(--s3); }
|
||||
.rail { right: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
|
||||
.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) {
|
||||
.rail { max-width: calc(100vw - var(--s3) * 2); }
|
||||
/* The provenance line and the key hints would collide in the last 40px
|
||||
of a phone, and the honesty line is the one that has to survive. */
|
||||
/* Everything hit by a finger clears 44px, which is 11 steps of the 4px
|
||||
rhythm and the smallest target anyone has managed to defend. `#help`
|
||||
was 17px tall. */
|
||||
#panel-toggle,
|
||||
#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 rail spans the width so the detail card — now reachable, because
|
||||
a marker picks on tap — is a readable line rather than a column. */
|
||||
.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; }
|
||||
.source { max-width: 60vw; }
|
||||
.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`), seeded from the
|
||||
viewport width by `main.ts` rather than by a media query, so that
|
||||
toggling it on at 375px actually shows it. */
|
||||
|
||||
/* 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; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -564,11 +779,6 @@
|
||||
<h1 id="title">San Francisco</h1>
|
||||
<p id="subtitle">Tera · Lumbridge Simulate</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>
|
||||
<nav id="cities" class="cities" aria-label="City"></nav>
|
||||
<button id="enter" class="enter">Enter the office →</button>
|
||||
@@ -577,27 +787,45 @@
|
||||
<p class="card" id="blurb"></p>
|
||||
</div>
|
||||
|
||||
<p class="card tier" id="tier" hidden></p>
|
||||
|
||||
<aside id="corner" class="corner" aria-label="Plan view">
|
||||
<div class="card" id="minimap">
|
||||
<div class="minimap-frame"></div>
|
||||
<p id="minimap-readout" class="minimap-readout"></p>
|
||||
</div>
|
||||
<p class="card tier" id="tier" hidden></p>
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
||||
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
||||
<span><kbd>O</kbd> office</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
aria-labelledby="shortcuts-title" hidden>
|
||||
<div class="card sheet">
|
||||
@@ -607,15 +835,36 @@
|
||||
<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>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>Tab</kbd></dt><dd>Every control, in reading order</dd>
|
||||
</dl>
|
||||
<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>
|
||||
<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
|
||||
ships in this build; a deployment wired to a live source says <em>live data</em>
|
||||
in the corner, and this one does not.</p>
|
||||
ships in this build. A deployment wired to a live source names it in the corner —
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+93
-10
@@ -23,23 +23,72 @@ without breaking the typecheck, which is the wrong order to find out.
|
||||
|
||||
## Routes
|
||||
|
||||
| route | body | cached |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/health` | `HealthBody` | never |
|
||||
| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` |
|
||||
| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` |
|
||||
| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` |
|
||||
| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only |
|
||||
| route | query | body | cached |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/v1/health` | — | `HealthBody` | never |
|
||||
| `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` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `WeatherBody` | public, `TERA_WEATHER_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 |
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
|
||||
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_PORT` | `8431` | |
|
||||
| `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_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_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. |
|
||||
| `TERA_ADSB_RADIUS_NM` | `40` | |
|
||||
| `TERA_ADSB_RADIUS_NM` | `40` | Clamped to 1–250 nm, which is what both feeds accept, with a `degraded` line. |
|
||||
| `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 5–15 s**. |
|
||||
| `TERA_FLIGHTS_SEED` | `4711` | |
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
anything not on the allowlist.** Serving a snapshot of geocoded coordinates is
|
||||
Public Use of a Derivative Database; if those coordinates came from Nominatim,
|
||||
|
||||
+61
-8
@@ -19,6 +19,7 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
|
||||
import { loadRegions, type RegionSet } from "./regions.ts";
|
||||
import type {
|
||||
AuthMode,
|
||||
FlightsSourceId,
|
||||
@@ -114,8 +115,17 @@ export interface Config {
|
||||
port: number;
|
||||
logLevel: 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 };
|
||||
/** 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. */
|
||||
corsOrigins: string[];
|
||||
/** `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 flights = loadFlights(env, degraded);
|
||||
const markers = loadMarkers(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 {
|
||||
host: str(env, "TERA_HOST", "127.0.0.1"),
|
||||
port: num(env, "TERA_PORT", 8431, degraded),
|
||||
logLevel: str(env, "TERA_LOG_LEVEL", "info"),
|
||||
version: readVersion(),
|
||||
origin: {
|
||||
lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
|
||||
lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded),
|
||||
},
|
||||
origin,
|
||||
regions: loadRegions({
|
||||
spec: str(env, "TERA_REGIONS", ""),
|
||||
origin,
|
||||
originConfigured:
|
||||
str(env, "TERA_ORIGIN_LAT", "") !== "" || str(env, "TERA_ORIGIN_LNG", "") !== "",
|
||||
degraded,
|
||||
}),
|
||||
corsOrigins: list(env, "TERA_CORS_ORIGIN"),
|
||||
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
|
||||
weather,
|
||||
@@ -236,7 +257,7 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
|
||||
return {
|
||||
source,
|
||||
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,
|
||||
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, 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"];
|
||||
|
||||
/** 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"];
|
||||
|
||||
function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
|
||||
function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersConfig {
|
||||
const asked = str(env, "TERA_MARKERS_SOURCE", "none");
|
||||
let source = oneOf(asked, MARKER_SOURCES);
|
||||
if (source === null) {
|
||||
@@ -268,6 +308,19 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
|
||||
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");
|
||||
return {
|
||||
source,
|
||||
|
||||
@@ -38,15 +38,36 @@ export interface FlightsSnapshot {
|
||||
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(
|
||||
endpoint: string,
|
||||
center: { lat: number; lng: number },
|
||||
radiusNm: number,
|
||||
log?: AdsbLog,
|
||||
): Promise<FlightsSnapshot | null> {
|
||||
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
|
||||
const body = await getJson<AircraftEnvelope>(url);
|
||||
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
|
||||
* 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 {
|
||||
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 {
|
||||
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[] = [];
|
||||
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;
|
||||
const callsign = a.flight?.trim();
|
||||
const id = a.hex ?? callsign;
|
||||
|
||||
+80
-34
@@ -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
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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 { Region } from "../regions.ts";
|
||||
import type { FlightsBody } from "../../../src/server/wire.ts";
|
||||
import { createUpstream } from "../upstream.ts";
|
||||
import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts";
|
||||
import { planFor } from "./plan.ts";
|
||||
|
||||
export interface FlightsService {
|
||||
current(): Promise<FlightsBody>;
|
||||
current(region: Region): Promise<FlightsBody>;
|
||||
}
|
||||
|
||||
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
|
||||
* 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_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 {
|
||||
const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights;
|
||||
const routes = planFor(config.origin);
|
||||
|
||||
const plan = (): FlightsBody => ({
|
||||
mode: "plan",
|
||||
source: "sim",
|
||||
t0: epochMs,
|
||||
seed,
|
||||
routes,
|
||||
ttlSeconds,
|
||||
// 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",
|
||||
source: "sim",
|
||||
t0: epochMs,
|
||||
seed,
|
||||
routes: planFor(region),
|
||||
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 {
|
||||
async current(): Promise<FlightsBody> {
|
||||
if (source === "sim") return plan();
|
||||
async current(region: Region): Promise<FlightsBody> {
|
||||
if (source === "sim") return plan(region);
|
||||
|
||||
if (Date.now() - polledAt > liveTtl * 1000) await poll();
|
||||
if (snapshot === null) return plan();
|
||||
const key = source === "dump1090" ? RECEIVER_KEY : region.id;
|
||||
const snapshot = await upstream.get(key, () =>
|
||||
source === "dump1090"
|
||||
? readDump1090(dump1090Path, log)
|
||||
: fetchAdsb(endpoint, region, radiusNm, log),
|
||||
);
|
||||
if (snapshot === null) return plan(region);
|
||||
|
||||
return {
|
||||
mode: "live",
|
||||
|
||||
+107
-17
@@ -11,11 +11,12 @@
|
||||
*
|
||||
* ### Where the coordinates came from
|
||||
*
|
||||
* The three Bay Area airport positions are published FAA airport reference
|
||||
* points, typed in by hand. They are US government facts in the public domain,
|
||||
* and — as with every other coordinate in this repo — emphatically not derived
|
||||
* from OpenStreetMap. 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 seven airport positions — SFO, OAK and SJC in the north, LAX, BUR, LGB and
|
||||
* SNA in the south — are published FAA airport reference points, typed in by
|
||||
* hand. They are US government facts in the public domain, and — as with every
|
||||
* other coordinate in this repo — emphatically not derived from OpenStreetMap.
|
||||
* 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
|
||||
* 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 BAY_AREA_RADIUS = 0.75;
|
||||
const LAX: [number, number] = [33.9425, -118.4081];
|
||||
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 east–west, 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 self-hoster pointing `TERA_ORIGIN_LAT/LNG` at somewhere that is not San
|
||||
* Francisco should get moving aircraft rather than an empty sky, so eight legs
|
||||
* are laid out on evenly-spaced bearings through their origin. It is not their
|
||||
* city's real airspace and does not pretend to be — it is motion in the right
|
||||
* kind of place, which is all the map ever wanted from this.
|
||||
* A self-hoster pointing `TERA_REGIONS` (or `TERA_ORIGIN_LAT/LNG`) at somewhere
|
||||
* that is neither of the two boards should get moving aircraft rather than an
|
||||
* empty sky, so eight legs are laid out on evenly-spaced bearings through the
|
||||
* region centre. It is not their city's real airspace and does not pretend to be
|
||||
* — 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[] {
|
||||
const routes: WireSimRoute[] = [];
|
||||
@@ -127,9 +211,15 @@ function genericPlan(lat: number, lng: number): WireSimRoute[] {
|
||||
return routes;
|
||||
}
|
||||
|
||||
export function planFor(origin: { lat: number; lng: number }): WireSimRoute[] {
|
||||
const nearSf =
|
||||
Math.abs(origin.lat - 37.7749) < BAY_AREA_RADIUS &&
|
||||
Math.abs(origin.lng + 122.4194) < BAY_AREA_RADIUS;
|
||||
return nearSf ? BAY_AREA : genericPlan(origin.lat, origin.lng);
|
||||
export function planFor(centre: { lat: number; lng: number }): WireSimRoute[] {
|
||||
for (const airspace of AIRSPACES) {
|
||||
const [lat, lng] = airspace.centre;
|
||||
if (
|
||||
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
@@ -9,28 +9,91 @@
|
||||
|
||||
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 {
|
||||
headers?: Record<string, string>;
|
||||
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
|
||||
* caller decides what a missing answer means; nothing here does.
|
||||
* `null` on any failure at all — transport, status, an oversized body, or one
|
||||
* 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> {
|
||||
const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { accept: "application/json", ...opts.headers },
|
||||
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
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 {
|
||||
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.
|
||||
*
|
||||
|
||||
+86
-10
@@ -9,9 +9,42 @@
|
||||
* 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
|
||||
* 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 { MarkersBody } from "../../../src/server/wire.ts";
|
||||
import { assertPublicShape } from "./gate.ts";
|
||||
@@ -29,24 +62,43 @@ interface Snapshot {
|
||||
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 {
|
||||
const { source, file, provenanceAllowlist, ttlSeconds } = config.markers;
|
||||
|
||||
let cached: MarkersBody | null = null;
|
||||
let readAt = 0;
|
||||
/** mtime and size of the file `cached` was built from. Empty means "unknown". */
|
||||
let signature = "";
|
||||
|
||||
async function load(): Promise<MarkersBody> {
|
||||
const now = new Date().toISOString();
|
||||
/** `null` when the file could not be read or parsed; the caller keeps what it has. */
|
||||
async function load(): Promise<MarkersBody | null> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(file, "utf8"));
|
||||
} catch (err) {
|
||||
log.warn(`markers: cannot read ${file} (${String(err)}); serving no markers`);
|
||||
return { markers: [], generatedAt: now, refused: [] };
|
||||
log.warn(
|
||||
`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 =
|
||||
Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot);
|
||||
|
||||
@@ -57,21 +109,45 @@ export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore {
|
||||
|
||||
return {
|
||||
markers: accepted,
|
||||
generatedAt: snapshot.generatedAt ?? now,
|
||||
generatedAt: snapshot.generatedAt ?? new Date().toISOString(),
|
||||
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 {
|
||||
async current(): Promise<MarkersBody> {
|
||||
if (source === "none") {
|
||||
return { markers: [], generatedAt: new Date().toISOString(), refused: [] };
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
return cached;
|
||||
|
||||
return cached ?? unreadable(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;
|
||||
}
|
||||
@@ -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
|
||||
* serves every viewer for its whole TTL. Aircraft are not personal data and this
|
||||
* body never varies by who asked.
|
||||
* serves every viewer of a region for its whole TTL. Aircraft are not personal
|
||||
* 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 { 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";
|
||||
|
||||
export function registerFlights(app: FastifyInstance, services: Services): void {
|
||||
app.get("/api/v1/flights", async (req, reply) => {
|
||||
const body = await services.flights.current();
|
||||
app.get<{ Querystring: RegionQuery }>("/api/v1/flights", async (req, reply) => {
|
||||
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);
|
||||
return body;
|
||||
});
|
||||
|
||||
@@ -10,17 +10,37 @@
|
||||
* `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
|
||||
* 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 { Region } from "../regions.ts";
|
||||
import type { HealthBody } from "../../../src/server/wire.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 {
|
||||
const { config, startedAt } = services;
|
||||
|
||||
app.get("/api/v1/health", async () => {
|
||||
const body: HealthBody = {
|
||||
const body: HealthBodyWithRegions = {
|
||||
ok: true,
|
||||
service: "tera-api",
|
||||
version: config.version,
|
||||
@@ -36,6 +56,10 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
|
||||
? config.auth.entryUrl
|
||||
: 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,
|
||||
};
|
||||
return body;
|
||||
|
||||
@@ -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
|
||||
* 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.
|
||||
* 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`,
|
||||
* which is what keeps a public snapshot from quietly becoming a Publicly Used
|
||||
* Derivative Database under ODbL.
|
||||
* ### Why a configured feed is refused to anonymous callers
|
||||
*
|
||||
* `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 { publicCache } from "../cache.ts";
|
||||
import type { ErrorBody } from "../../../src/server/wire.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 {
|
||||
app.get("/api/v1/markers", async (req, reply) => {
|
||||
const body = await services.markers.current();
|
||||
publicCache(req, reply, services.config.markers.ttlSeconds);
|
||||
return body;
|
||||
if (services.config.markers.source === "none") {
|
||||
const body = await services.markers.current();
|
||||
publicCache(req, reply, services.config.markers.ttlSeconds);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* produces `synthetic: true` and a clear day — there is no failure mode here 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.
|
||||
* `?city=sf`, or `?lat=&lng=` which resolves to whichever configured region
|
||||
* claims the point, or neither, which answers for the default region. The
|
||||
* validation and the refusal both live in `regions.ts`; the paragraph there on
|
||||
* 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 { 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";
|
||||
|
||||
export function registerWeather(app: FastifyInstance, services: Services): void {
|
||||
app.get("/api/v1/weather", async (req, reply) => {
|
||||
const body = await services.weather.current();
|
||||
app.get<{ Querystring: RegionQuery }>("/api/v1/weather", async (req, reply) => {
|
||||
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);
|
||||
return body;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+35
-41
@@ -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`.
|
||||
* 2. **A dead upstream serves the last good observation**, and only falls back
|
||||
* to the clear day if there has never been one. Ten-minute-old weather is
|
||||
* better than no weather and much better than a 503.
|
||||
* 2. **A dead upstream serves the last good observation** for that place, and
|
||||
* only falls back to the clear day if there has never been one. Ten-minute-old
|
||||
* 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
|
||||
* outbound requests, which matters when the source is a public good with a
|
||||
* 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 1–3 live in `upstream.ts` now, because flights wanted exactly the same
|
||||
* three.
|
||||
*/
|
||||
|
||||
import type { Config } from "../config.ts";
|
||||
import type { Region } from "../regions.ts";
|
||||
import type { WeatherBody } from "../../../src/server/wire.ts";
|
||||
import { createUpstream } from "../upstream.ts";
|
||||
import { fetchMetno } from "./metno.ts";
|
||||
import { fetchNws } from "./nws.ts";
|
||||
import { fetchOpenMeteo } from "./openmeteo.ts";
|
||||
import { clearDay } from "./synthetic.ts";
|
||||
|
||||
export interface WeatherService {
|
||||
current(): Promise<WeatherBody>;
|
||||
current(region: Region): Promise<WeatherBody>;
|
||||
}
|
||||
|
||||
export interface WeatherLog {
|
||||
@@ -28,46 +40,28 @@ export interface WeatherLog {
|
||||
}
|
||||
|
||||
export function createWeatherService(config: Config, log: WeatherLog): WeatherService {
|
||||
const { lat, lng } = config.origin;
|
||||
const { source, contact, ttlSeconds } = config.weather;
|
||||
const upstream = createUpstream<WeatherBody>({ label: `weather:${source}`, ttlSeconds, log });
|
||||
|
||||
let cached: WeatherBody | null = null;
|
||||
let fetchedAt = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const fresh =
|
||||
source === "nws"
|
||||
? await fetchNws(lat, lng, contact)
|
||||
: source === "metno"
|
||||
? await fetchMetno(lat, lng, contact)
|
||||
: source === "openmeteo"
|
||||
? await fetchOpenMeteo(lat, lng)
|
||||
: 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"}`);
|
||||
function fetchFor(region: Region): Promise<WeatherBody | null> {
|
||||
// The coordinate that goes upstream is the *region centre*, never the one
|
||||
// the caller sent. See the top of `regions.ts` for why that distinction is
|
||||
// the whole abuse story on this endpoint.
|
||||
const { lat, lng } = region;
|
||||
return source === "nws"
|
||||
? fetchNws(lat, lng, contact)
|
||||
: source === "metno"
|
||||
? fetchMetno(lat, lng, contact)
|
||||
: source === "openmeteo"
|
||||
? fetchOpenMeteo(lat, lng)
|
||||
: Promise.resolve(null);
|
||||
}
|
||||
|
||||
return {
|
||||
async current(): Promise<WeatherBody> {
|
||||
if (source === "none") return clearDay(lat, lng);
|
||||
|
||||
const stale = Date.now() - fetchedAt > ttlSeconds * 1000;
|
||||
if (stale) {
|
||||
// Collapse concurrent misses into one upstream request.
|
||||
inFlight ??= refresh().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
await inFlight;
|
||||
}
|
||||
return cached ?? clearDay(lat, lng);
|
||||
async current(region: Region): Promise<WeatherBody> {
|
||||
if (source === "none") return clearDay(region.lat, region.lng);
|
||||
const body = await upstream.get(region.id, () => fetchFor(region));
|
||||
return body ?? clearDay(region.lat, region.lng);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* Getting an observation takes three hops — point to station list, station list
|
||||
* to nearest station, station to latest observation — so the first two are
|
||||
* 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";
|
||||
|
||||
+72
-5
@@ -86,12 +86,45 @@ export interface Capabilities {
|
||||
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 {
|
||||
tier: Tier;
|
||||
subject: string | null;
|
||||
/** Where to send someone who is not signed in. `null` means this deployment has no door. */
|
||||
signInUrl: string | null;
|
||||
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> {
|
||||
const health = await getJson<{
|
||||
auth?: { mode?: unknown; entryUrl?: unknown };
|
||||
sources?: unknown;
|
||||
}>(fetcher, "/health");
|
||||
|
||||
// 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 mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
|
||||
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
|
||||
// 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<{
|
||||
authenticated?: unknown;
|
||||
@@ -211,12 +246,32 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
*/
|
||||
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
|
||||
|
||||
if (!authenticated) return access("anon", null, signInUrl);
|
||||
return access(admin ? "god" : "member", subject, signInUrl);
|
||||
if (!authenticated) return access("anon", null, signInUrl, feeds);
|
||||
return access(admin ? "god" : "member", subject, signInUrl, feeds);
|
||||
}
|
||||
|
||||
function access(tier: Tier, subject: string | null, signInUrl: string | null): Access {
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier) };
|
||||
function access(
|
||||
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 {
|
||||
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 {
|
||||
const url = new URL(raw, window.location.origin);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
|
||||
+16
-3
@@ -35,16 +35,29 @@ it does not have to happen.
|
||||
```ts
|
||||
const tera = createTeraClient(); // same-origin /api/v1
|
||||
const markers = await tera.markers();
|
||||
const scene = createScene(canvas, {
|
||||
const scene = await createScene(stage, {
|
||||
city: SAN_FRANCISCO,
|
||||
markerPalette: markers.palette,
|
||||
flights: tera.flights(),
|
||||
flights: tera.flights(regionOf(SAN_FRANCISCO), sampleRoutesFor("sf")),
|
||||
});
|
||||
scene.setMarkers(markers.value);
|
||||
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
|
||||
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
|
||||
is real — the sample keys are not your keys.
|
||||
|
||||
+719
-69
@@ -11,19 +11,37 @@
|
||||
*
|
||||
* **Every call degrades instead of failing.** No server, a 404, a static host
|
||||
* answering `/api/v1/markers` with its own index.html, a network that has gone
|
||||
* away mid-session: all of it lands on the sample data in `sample.ts` and the
|
||||
* synthetic clear day below, and the map keeps rendering. That is not defensive
|
||||
* habit, it is the acceptance test the whole repo is held to — a stranger clones
|
||||
* this, runs one command, and gets a city, with no account, no key and no
|
||||
* network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
|
||||
* away mid-session: all of it lands on the sample data in `sample.ts` and on a
|
||||
* sky nobody claims to have observed, and the map keeps rendering. That is not
|
||||
* defensive habit, it is the acceptance test the whole repo is held to — a
|
||||
* stranger clones this, runs one command, and gets a city, with no account, no
|
||||
* key and no network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
|
||||
* working Tera; pointing it at a server is an upgrade, not a requirement.
|
||||
*
|
||||
* **Everything that is about a place takes the place as an argument.** Weather
|
||||
* and traffic are both per-city and this build has two cities nearly six hundred
|
||||
* kilometres apart, so a client that asked "what is the weather" without
|
||||
* saying where would be asking the server to guess — and the server's guess is
|
||||
* a single `TERA_ORIGIN_LAT/LNG` pair chosen at deploy time, which is right for
|
||||
* at most one of them. The concrete failure is San Francisco's fog rolling over
|
||||
* Long Beach; every location parameter and every relevance check below exists
|
||||
* to make that impossible rather than unlikely.
|
||||
*
|
||||
* The wire types live in `src/server/wire.ts` and are types only, so importing
|
||||
* them costs the bundle nothing.
|
||||
*/
|
||||
|
||||
import type { WeatherObservation } from "../engine/atmosphere.ts";
|
||||
import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts";
|
||||
import {
|
||||
distanceNm,
|
||||
inRegion,
|
||||
sampleRoute,
|
||||
SimulatedFlights,
|
||||
syntheticRoutes,
|
||||
type Place,
|
||||
type SimRoute,
|
||||
type SkyRegion,
|
||||
} from "../engine/flights.ts";
|
||||
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { seededRandom } from "../engine/world.ts";
|
||||
import type {
|
||||
@@ -34,7 +52,7 @@ import type {
|
||||
OfficeDoc,
|
||||
WeatherBody,
|
||||
} from "../server/wire.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
|
||||
|
||||
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
|
||||
const DEFAULT_BASE = "/api/v1";
|
||||
@@ -45,6 +63,19 @@ const DEFAULT_TIMEOUT_MS = 4000;
|
||||
/** How long to wait before trying the flights endpoint again after it fails. */
|
||||
const RETRY_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* How long to wait before asking again for something the server answered about
|
||||
* a different part of the world.
|
||||
*
|
||||
* Fifteen minutes, and it is a back-off rather than a give-up on purpose. A box
|
||||
* pinned to one origin will keep answering about that origin for as long as it
|
||||
* is configured that way, so polling it every TTL is spending a request on a
|
||||
* body that gets thrown away — but the thing that changes the answer is a
|
||||
* redeploy, which happens, and a client that stopped asking would need a reload
|
||||
* to notice.
|
||||
*/
|
||||
const ELSEWHERE_SECONDS = 900;
|
||||
|
||||
export interface TeraApiOptions {
|
||||
/**
|
||||
* Base URL, with no trailing slash. Absolute is allowed and is what a
|
||||
@@ -97,20 +128,82 @@ export interface MarkerFeed extends Feed<Marker[]> {
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface WeatherFeed extends Feed<WeatherObservation> {
|
||||
/**
|
||||
* The sky, or an admission that nobody knows what the sky is doing.
|
||||
*
|
||||
* `value` is nullable and that null is load-bearing rather than lazy. It is
|
||||
* exactly `Environment.weather` in `atmosphere.ts`, where `null` means "nobody
|
||||
* was asked" and lets the local climatology run, and a `WeatherObservation`
|
||||
* means somebody looked — which `apply` then treats as authority over the
|
||||
* model. So the type matches the argument it is destined for, the caller can
|
||||
* hand `feed.value` straight to `observe()`, and there is no shape in which a
|
||||
* failed fetch can be mistaken for a report of a clear sky. See
|
||||
* `noObservation` for what that mistake actually did to the fog.
|
||||
*/
|
||||
export interface WeatherFeed extends Feed<WeatherObservation | null> {
|
||||
/**
|
||||
* ISO-8601 observation time, or `null` when nobody observed anything.
|
||||
*
|
||||
* The *observation* time and not the fetch time, which is the field's whole
|
||||
* value: the server serves from a ten-minute cache, so a body that arrived a
|
||||
* second ago can already describe a sky from ten minutes ago, and the only
|
||||
* way to know how old the weather is is to be told.
|
||||
*/
|
||||
observedAt: string | null;
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A live weather feed for one place, polled until somebody stops it.
|
||||
*
|
||||
* A watch and not a promise because weather has no natural moment: the map is
|
||||
* open for an hour, the marine layer arrives at some point during it, and a
|
||||
* value fetched once at boot is a photograph of a sky that has since changed.
|
||||
*
|
||||
* `stop()` is not optional housekeeping. Switching city while a poll is in
|
||||
* flight is the ordinary case, not the rare one — the request takes a second
|
||||
* and the button takes a moment — and an answer for the old city landing in the
|
||||
* new city's rig is San Francisco's fog over Long Beach. So a stopped watch
|
||||
* aborts what it has in the air and refuses to publish anything that arrives
|
||||
* anyway.
|
||||
*/
|
||||
export interface WeatherWatch {
|
||||
/** The latest feed. Nobody-was-asked, and not live, until an answer lands. */
|
||||
current(): WeatherFeed;
|
||||
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
|
||||
refresh(): void;
|
||||
/** Stop polling, abort anything in flight, and drop any late answer. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface TeraClient {
|
||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||
health(): Promise<HealthBody | null>;
|
||||
markers(): Promise<MarkerFeed>;
|
||||
weather(): Promise<WeatherFeed>;
|
||||
/**
|
||||
* The traffic source, built once. It fetches on its own schedule and never
|
||||
* blocks the render loop; see `HttpFlights`.
|
||||
* The sky over one place, once.
|
||||
*
|
||||
* `at` is required. There is no sensible default for it — see the note at the
|
||||
* top of this file — and a default would have been the bug.
|
||||
*/
|
||||
flights(): FlightSource;
|
||||
weather(at: Place, options?: { signal?: AbortSignal }): Promise<WeatherFeed>;
|
||||
/**
|
||||
* The sky over one place, kept up to date. `onFeed` fires once per settled
|
||||
* poll, including the ones that change nothing.
|
||||
*/
|
||||
watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch;
|
||||
/**
|
||||
* The traffic source for one region. It fetches on its own schedule and never
|
||||
* blocks the render loop; see `HttpFlights`.
|
||||
*
|
||||
* `fallbackRoutes` is what the simulator flies while the network has not
|
||||
* answered, and defaults to something generated inside the region rather than
|
||||
* to this repo's sample set — the sample set is over San Francisco, and a
|
||||
* default that is only correct for one city is the failure this signature was
|
||||
* changed to prevent. Callers with hand-authored corridors for the city
|
||||
* should pass them; `sampleRoutesFor` in `sample.ts` has them.
|
||||
*/
|
||||
flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource;
|
||||
/**
|
||||
* One office pack. `null` for anything the server will not serve — including
|
||||
* a private one, which answers 404 rather than 403 so the endpoint cannot be
|
||||
@@ -123,6 +216,23 @@ export interface TeraClient {
|
||||
office(id: string): Promise<OfficeDoc | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One GET's worth of options: what to put in the query string, and a way for
|
||||
* the caller to give up on it.
|
||||
*
|
||||
* The signal is the caller's, and is in addition to this module's own timeout
|
||||
* rather than instead of it. They cancel different things: the timeout is about
|
||||
* a server that is slow, and the signal is about an answer that has stopped
|
||||
* being wanted — a city switched, a page unloading — which can happen well
|
||||
* inside a healthy response time.
|
||||
*/
|
||||
interface GetOptions {
|
||||
query?: Record<string, string | number>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
type Get = <T>(path: string, options?: GetOptions) => Promise<T | null>;
|
||||
|
||||
export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, "");
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
@@ -131,17 +241,25 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
/**
|
||||
* One GET, and `null` for every way it can go wrong.
|
||||
*
|
||||
* Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static
|
||||
* host serving `index.html` with a 200 and an HTML content type — the caller's
|
||||
* response to all of them is the same, and a taxonomy of failures nobody
|
||||
* branches on is a taxonomy nobody maintains.
|
||||
* Deliberately undiscriminating. A 404, a timeout, a CORS refusal, an abort, a
|
||||
* static host serving `index.html` with a 200 and an HTML content type — the
|
||||
* caller's response to all of them is the same, and a taxonomy of failures
|
||||
* nobody branches on is a taxonomy nobody maintains.
|
||||
*
|
||||
* Note that an abort therefore looks exactly like a failure. Everything that
|
||||
* aborts on purpose here checks its own cancelled flag before doing anything
|
||||
* with the `null`, because treating "you asked me to stop" as "the server is
|
||||
* down" would have a city switch trip the back-off ladder.
|
||||
*/
|
||||
async function get<T>(path: string): Promise<T | null> {
|
||||
const get: Get = async <T,>(path: string, opts: GetOptions = {}): Promise<T | null> => {
|
||||
if (!doFetch) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const abort = () => controller.abort();
|
||||
opts.signal?.addEventListener("abort", abort);
|
||||
if (opts.signal?.aborted) controller.abort();
|
||||
const timer = setTimeout(abort, timeoutMs);
|
||||
try {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
const res = await doFetch(`${base}${path}${queryString(opts.query)}`, {
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
@@ -156,10 +274,9 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
let flightSource: FlightSource | null = null;
|
||||
};
|
||||
|
||||
return {
|
||||
health: () => get<HealthBody>("/health"),
|
||||
@@ -189,43 +306,318 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
};
|
||||
},
|
||||
|
||||
async weather(): Promise<WeatherFeed> {
|
||||
const body = await get<WeatherBody>("/weather");
|
||||
if (!body) return { value: CLEAR_DAY, live: false, attribution: [] };
|
||||
// `WeatherBody` is structurally a `WeatherObservation` plus fields no
|
||||
// renderer reads, which `atmosphere.ts` says in as many words. The extra
|
||||
// fields ride along harmlessly and the engine never sees them.
|
||||
return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] };
|
||||
async weather(at: Place, opts: { signal?: AbortSignal } = {}): Promise<WeatherFeed> {
|
||||
const body = await get<WeatherBody>("/weather", {
|
||||
query: whereQuery(at),
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
});
|
||||
return weatherFeed(at, body);
|
||||
},
|
||||
|
||||
flights(): FlightSource {
|
||||
flightSource ??= new HttpFlights(get, SAMPLE_ROUTES);
|
||||
return flightSource;
|
||||
watchWeather(at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch {
|
||||
return watchWeather(get, at, onFeed);
|
||||
},
|
||||
|
||||
flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource {
|
||||
return new HttpFlights(get, region, fallbackRoutes ?? syntheticRoutes(region));
|
||||
},
|
||||
|
||||
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Weather --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The clear day a zero-config box serves, restated in the browser.
|
||||
* How often a watch asks, when the last answer was a good one.
|
||||
*
|
||||
* The server does this too — a weather source configured without what it needs
|
||||
* is demoted rather than fatal, and it answers `synthetic: true` forever
|
||||
* (CONTRACT.md §5.1). This is the same answer for the case where there is no
|
||||
* server at all. Note what it does *not* do: `visibilityKm` stays null, which
|
||||
* `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San
|
||||
* Francisco's marine layer still runs off its own climatology instead of being
|
||||
* overruled by a fact nobody observed.
|
||||
* Ten minutes, which is the server's own `TERA_WEATHER_TTL` default and not a
|
||||
* coincidence: asking faster spends a request to be handed the same cached body
|
||||
* back. The upstreams behind that cache agree about the order of magnitude —
|
||||
* NWS publishes observations hourly, MET Norway's terms ask callers to respect
|
||||
* the `Expires` header rather than poll on their own clock, and Open-Meteo's
|
||||
* current block moves quarter-hourly. Nothing that arrives faster than ten
|
||||
* minutes is new information.
|
||||
*
|
||||
* The other end of the argument is what the map does with it. Cloud cover moves
|
||||
* a directional light's intensity and a fog distance, both of which are ramped
|
||||
* over seconds by `atmosphere.ts` anyway, so a late arrival looks like weather
|
||||
* changing and never like a jump. This is a map, not a dashboard: the marine
|
||||
* layer arriving three minutes after it really did is not an error anybody can
|
||||
* detect, and a poll a minute for eight hours is 480 requests to find that out.
|
||||
*/
|
||||
const CLEAR_DAY: WeatherObservation = {
|
||||
cloudCover: 0.1,
|
||||
precipitation: 0,
|
||||
visibilityKm: null,
|
||||
windKph: null,
|
||||
windDirDeg: null,
|
||||
condition: "clear",
|
||||
};
|
||||
const WEATHER_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* The ceiling on the back-off ladder a failing watch climbs.
|
||||
*
|
||||
* Backing off is the *normal* path here rather than an outage measure. The
|
||||
* commonest deployment of this bundle is a static host with no API at all, and
|
||||
* on one of those every poll fails forever — so the delay doubles from the ten
|
||||
* minute interval up to an hour and stays there, and a tab left open overnight
|
||||
* makes a dozen requests instead of fifty. The delay never shortens on failure,
|
||||
* which is the retry storm this exists to not be.
|
||||
*/
|
||||
const WEATHER_MAX_INTERVAL_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* How far a reported observation may be from the place that was asked about
|
||||
* before it is somebody else's weather.
|
||||
*
|
||||
* A hundred and fifty kilometres, and both bounds on that number are real. It
|
||||
* has to be large: a station anywhere on the Bay Area board is a perfectly good
|
||||
* answer for the Bay Area, and the far corner of that board is a hundred and
|
||||
* seventeen kilometres from the point this client asks about, so a tight radius
|
||||
* would throw away correct observations. It has to be small: the two cities in
|
||||
* this build are five hundred and ninety kilometres apart, and the failure being
|
||||
* defended against is a server holding one `TERA_ORIGIN_LAT/LNG` answering every
|
||||
* request with San Francisco's fog while somebody looks at Long Beach. Anything
|
||||
* from about a hundred and twenty to about three hundred separates those two
|
||||
* cases cleanly.
|
||||
*
|
||||
* This is what makes the client safe against a server that ignores the location
|
||||
* it was given — which is every server built before this parameter existed.
|
||||
* `WeatherBody.location` says where the observation is actually from, so the
|
||||
* check is on the answer rather than on a promise about the question.
|
||||
*/
|
||||
const WEATHER_RELEVANCE_KM = 150;
|
||||
|
||||
/** Kilometres in a nautical mile, for the one place the two units meet. */
|
||||
const KM_PER_NM = 1.852;
|
||||
|
||||
/**
|
||||
* How old an observation may be before the map stops calling it the weather.
|
||||
*
|
||||
* An hour, measured from `observedAt` rather than from when the body arrived,
|
||||
* because a body that has just arrived can already be ten minutes old — see
|
||||
* `WeatherFeed.observedAt`. Under that hour a failed poll holds the last good
|
||||
* observation instead of reverting: a deployment that has been showing real
|
||||
* weather all afternoon and drops one request should keep showing it, which is
|
||||
* the same rule `HttpFlights` follows for traffic and for the same reason.
|
||||
*
|
||||
* The hour itself is the marine layer's. Fog over the western half of San
|
||||
* Francisco burns back to the coast in about that on a summer morning, so an
|
||||
* hour-old sky presented as the current one is precisely the lie the `live`
|
||||
* flag was added to prevent — and past that point, handing the sky back to the
|
||||
* local model is the more honest picture.
|
||||
*/
|
||||
const WEATHER_STALE_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* What every failure resolves to: nobody was asked.
|
||||
*
|
||||
* `null` and emphatically **not** a clear day, which is what this returned
|
||||
* first and what the fallback in an earlier draft of this file was. The two
|
||||
* are different to `atmosphere.ts` in a way that is easy to miss and very
|
||||
* visible on screen. A `WeatherObservation` saying `condition: "clear"` with no
|
||||
* visibility reported is an *observation of a clear sky*, and `apply` treats a
|
||||
* reported clear sky as authoritative: `observed === 0` suppresses the modelled
|
||||
* obscuration outright, on the entirely correct principle that somebody who
|
||||
* looked out of the window beats a climatology. Hand it a clear day the
|
||||
* moment the API 404s and San Francisco loses its marine layer — permanently,
|
||||
* on a zero-config box, which is the commonest way this bundle is run and the
|
||||
* one configuration where the local model is all there is.
|
||||
*
|
||||
* `null` means nobody looked, `apply` runs the marine layer off the season and
|
||||
* the hour, and the fog comes in over the Sunset on a June morning with no
|
||||
* server involved at all.
|
||||
*/
|
||||
function noObservation(): WeatherFeed {
|
||||
return { value: null, live: false, observedAt: null, attribution: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* One weather body, judged.
|
||||
*
|
||||
* Four outcomes and only one of them is an observation. No body at all is
|
||||
* nobody-was-asked. A body about somewhere else is *also* nobody-was-asked,
|
||||
* deliberately: rendering a real observation of a place the viewer is not
|
||||
* looking at is worse than rendering none, because it is wrong and it is
|
||||
* convincing. A `synthetic` body is the server saying in as many words that it
|
||||
* has no source — its numbers were invented by `weather/synthetic.ts` and are
|
||||
* not evidence of anything, so they are dropped for the same reason, and the
|
||||
* local model gets to run instead of being overruled by a fact nobody observed.
|
||||
* What is left is an observation, and it is the only thing that is live.
|
||||
*/
|
||||
function weatherFeed(at: Place, body: WeatherBody | null): WeatherFeed {
|
||||
if (!body) return noObservation();
|
||||
if (body.synthetic) return noObservation();
|
||||
if (elsewhere(at, body)) return noObservation();
|
||||
// `WeatherBody` is structurally a `WeatherObservation` plus fields no
|
||||
// renderer reads, which `atmosphere.ts` says in as many words. The extra
|
||||
// fields ride along harmlessly and the engine never sees them.
|
||||
return {
|
||||
value: body,
|
||||
live: true,
|
||||
observedAt: body.observedAt ?? null,
|
||||
attribution: body.attribution ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a body describes a different part of the world from the one asked about. */
|
||||
function elsewhere(at: Place, body: WeatherBody): boolean {
|
||||
const where = body.location;
|
||||
// A body with no location is one this client cannot place, and an
|
||||
// unplaceable observation is exactly as useful as a wrong one.
|
||||
if (!where || typeof where.lat !== "number" || typeof where.lng !== "number") return true;
|
||||
return distanceNm(at, where) * KM_PER_NM > WEATHER_RELEVANCE_KM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one place's weather until told to stop.
|
||||
*
|
||||
* Free of any timer the caller has to own. `atmosphere.apply` is pure and the
|
||||
* scene relights from whatever it is handed, so the honest shape is a callback
|
||||
* on new information rather than something the render loop has to remember to
|
||||
* ask.
|
||||
*/
|
||||
function watchWeather(get: Get, at: Place, onFeed: (feed: WeatherFeed) => void): WeatherWatch {
|
||||
let feed = noObservation();
|
||||
let receivedAt = 0;
|
||||
/**
|
||||
* When a poll last *settled*, successfully or not — which is a different fact
|
||||
* from when an answer last arrived, and the one the wake-up check needs.
|
||||
*
|
||||
* `receivedAt` is written only on the success path, so on a deployment whose
|
||||
* weather source is configured and failing it stays `0` forever and
|
||||
* `Date.now() - 0` clears every threshold there is. `onVisible` was gated on
|
||||
* it, so every alt-tab back to the map cancelled whichever rung of the
|
||||
* back-off ladder was pending and fired an immediate request: twenty
|
||||
* alt-tabs, twenty requests, which is precisely what
|
||||
* `WEATHER_MAX_INTERVAL_MS` exists not to do. `server/src/upstream.ts` states
|
||||
* the same rule from the other side and calls a clock that only a success
|
||||
* stamps the bug that turns somebody else's outage into your outbound flood.
|
||||
*
|
||||
* `receivedAt` stays, because `tooOld()` is genuinely asking "how old is what
|
||||
* I am showing" and a failed poll does not make it any fresher.
|
||||
*/
|
||||
let attemptedAt = 0;
|
||||
/** The delay the last settled poll asked for, so the wake-up can respect it. */
|
||||
let nextDelayMs = 0;
|
||||
let failures = 0;
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Whether what is in hand is still worth showing.
|
||||
*
|
||||
* Prefers the observation time on the body and falls back to when it arrived,
|
||||
* which is the answer for a source that did not stamp one.
|
||||
*/
|
||||
function tooOld(): boolean {
|
||||
const stamped = feed.observedAt === null ? NaN : Date.parse(feed.observedAt);
|
||||
const since = Number.isNaN(stamped) ? receivedAt : stamped;
|
||||
return Date.now() - since > WEATHER_STALE_MS;
|
||||
}
|
||||
|
||||
function schedule(delayMs: number) {
|
||||
if (stopped) return;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
nextDelayMs = delayMs;
|
||||
timer = setTimeout(() => void tick(), delayMs);
|
||||
}
|
||||
|
||||
function publish(next: WeatherFeed) {
|
||||
// A repeated fallback is not news. Every real observation is a fresh object
|
||||
// so it always gets through; two of nothing in a row are both `null`, and
|
||||
// publishing the second only asks the scene to relight itself identically.
|
||||
if (next.value === feed.value && next.live === feed.live) return;
|
||||
feed = next;
|
||||
onFeed(next);
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
timer = null;
|
||||
if (stopped) return;
|
||||
// Nothing reaches this with a request already out, but a watch that stalled
|
||||
// would stay stalled until the page reloaded, and that is too quiet a
|
||||
// failure to leave to the reasoning being right.
|
||||
if (inFlight) {
|
||||
schedule(WEATHER_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
inFlight = new AbortController();
|
||||
const body = await get<WeatherBody>("/weather", {
|
||||
query: whereQuery(at),
|
||||
signal: inFlight.signal,
|
||||
});
|
||||
inFlight = null;
|
||||
// The watch was stopped while this was in the air. Whatever came back is
|
||||
// the old city's sky and must not be published — `stop()` has already
|
||||
// aborted the request and this is the belt to that pair of braces. It is
|
||||
// also why a cancelled request must not count as a failure below.
|
||||
if (stopped) return;
|
||||
// Every settled poll, either branch. Deliberately not set for the abort
|
||||
// above: "you asked me to stop" is not an attempt that tells us anything
|
||||
// about the server.
|
||||
attemptedAt = Date.now();
|
||||
|
||||
if (!body) {
|
||||
failures += 1;
|
||||
// Hold what is in hand until it is too old to be honest about.
|
||||
if (feed.live && tooOld()) publish(noObservation());
|
||||
schedule(Math.min(WEATHER_INTERVAL_MS * 2 ** (failures - 1), WEATHER_MAX_INTERVAL_MS));
|
||||
return;
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
receivedAt = Date.now();
|
||||
// `weatherFeed` refuses this body too; the branch is here for the schedule.
|
||||
// A box answering about another city will answer that way until somebody
|
||||
// redeploys it, which is not worth a request every ten minutes — whereas a
|
||||
// `synthetic` body, which is also refused, comes from a source that may
|
||||
// come back, and is worth asking about again on the ordinary cadence.
|
||||
if (elsewhere(at, body)) {
|
||||
publish(noObservation());
|
||||
schedule(ELSEWHERE_SECONDS * 1000);
|
||||
return;
|
||||
}
|
||||
publish(weatherFeed(at, body));
|
||||
schedule(WEATHER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask again on the way back into a tab that has been away.
|
||||
*
|
||||
* A laptop shut for six hours wakes showing the sky from before lunch, and
|
||||
* waiting out the rest of a ten-minute interval in front of it is a long time
|
||||
* to look at stale fog. Browsers throttle timers in hidden tabs and may not
|
||||
* have fired ours at all, so the wake-up is the event worth listening for
|
||||
* rather than a shorter interval that would cost a request every time.
|
||||
*/
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
// Against the delay that is actually pending, so a source on the back-off
|
||||
// ladder is left where it is. On a healthy watch that delay is
|
||||
// `WEATHER_INTERVAL_MS` and this behaves exactly as it always did.
|
||||
if (Date.now() - attemptedAt >= nextDelayMs) refresh();
|
||||
};
|
||||
const hasDocument = typeof document !== "undefined";
|
||||
if (hasDocument) document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
function refresh() {
|
||||
if (stopped || inFlight) return;
|
||||
schedule(0);
|
||||
}
|
||||
|
||||
schedule(0);
|
||||
|
||||
return {
|
||||
current: () => feed,
|
||||
refresh,
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
inFlight?.abort();
|
||||
inFlight = null;
|
||||
if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Markers --------------------------------------------------------------
|
||||
|
||||
function sampleMarkerFeed(): MarkerFeed {
|
||||
return {
|
||||
@@ -238,8 +630,60 @@ function sampleMarkerFeed(): MarkerFeed {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Asking about a place -------------------------------------------------
|
||||
|
||||
/**
|
||||
* The location half of a query, rounded to about a kilometre.
|
||||
*
|
||||
* Rounded for the shared cache, which is the whole reason the precision is
|
||||
* thrown away. `/weather` and `/flights` are served with a public cache header
|
||||
* and the query string is part of the cache key, so two viewers of the same
|
||||
* city have to produce byte-identical URLs or the cache is a per-viewer cache
|
||||
* and the upstream gets hit once per person. A city centre is a constant in
|
||||
* this build and would round identically anyway; a caller that ever passes a
|
||||
* camera position instead gets the same protection for free, along with not
|
||||
* having put anybody's exact position in an access log.
|
||||
*/
|
||||
function whereQuery(at: Place): Record<string, string> {
|
||||
return { lat: at.lat.toFixed(2), lng: at.lng.toFixed(2) };
|
||||
}
|
||||
|
||||
function queryString(query: Record<string, string | number> | undefined): string {
|
||||
if (!query) return "";
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) params.set(key, String(value));
|
||||
const encoded = params.toString();
|
||||
return encoded === "" ? "" : `?${encoded}`;
|
||||
}
|
||||
|
||||
// ---- Traffic --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A `FlightSource` that also knows whether what it is handing over is real.
|
||||
*
|
||||
* The extra method is here rather than on `FlightSource` in `engine/types.ts`
|
||||
* because the engine has no business with provenance: it draws darts at
|
||||
* coordinates, and whether the coordinates were observed is a question about
|
||||
* the deployment. Only the interface layer asks it, so only this layer declares
|
||||
* it.
|
||||
*/
|
||||
export interface TrafficSource extends FlightSource {
|
||||
/** True while the aircraft `poll()` returns are observed positions for this region. */
|
||||
live(): boolean;
|
||||
/**
|
||||
* Credit lines for whatever is currently being drawn, and empty when nothing
|
||||
* on screen came from anybody else.
|
||||
*
|
||||
* Here for the same reason `live()` is: the engine draws darts and has no
|
||||
* business with provenance, but a community feed that asks to be named has
|
||||
* asked the *deployment*, and this is the layer that knows a deployment
|
||||
* exists. `describeLiveness` says what is live; this says who to thank for it.
|
||||
*/
|
||||
attribution(): string[];
|
||||
/** Stop fetching and abort anything in flight. Idempotent. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic over HTTP, in whichever of the two shapes the server chose.
|
||||
*
|
||||
@@ -257,10 +701,20 @@ function sampleMarkerFeed(): MarkerFeed {
|
||||
* refetched.
|
||||
*
|
||||
* Until the first response lands, and after any failure, this is the simulator
|
||||
* over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and
|
||||
* the invented one is labelled as such in `sample.ts`.
|
||||
* over whatever routes the caller handed in. An empty sky is a worse answer than
|
||||
* an invented one, and the invented one is labelled as such in `sample.ts`.
|
||||
*
|
||||
* **Everything that arrives is checked against the region before it is drawn.**
|
||||
* The region goes out on the query and is checked again on the way back, which
|
||||
* is not belt and braces — a server built before that parameter existed answers
|
||||
* every caller from its single configured origin, and it answers 200. A plan
|
||||
* whose routes are all somewhere else, or a snapshot with aircraft in it but
|
||||
* none of them here, is not this city's sky and is refused in favour of the
|
||||
* simulator. The failure this prevents is not subtle: San Francisco's traffic
|
||||
* over the SoCal board projects clean off the world and renders as nothing at
|
||||
* all, so the map looks broken rather than wrong.
|
||||
*/
|
||||
class HttpFlights implements FlightSource {
|
||||
class HttpFlights implements TrafficSource {
|
||||
/**
|
||||
* One second, which is the *evaluation* cadence and not the request cadence.
|
||||
* A plan is arithmetic and wants to be evaluated every frame or close to it;
|
||||
@@ -269,14 +723,18 @@ class HttpFlights implements FlightSource {
|
||||
readonly interval = 1;
|
||||
|
||||
private readonly fallback: SimulatedFlights;
|
||||
private mode: "fallback" | "plan" | "live" = "fallback";
|
||||
private plan: FlightsPlanBody | null = null;
|
||||
private planPhase: number[] = [];
|
||||
private live: Aircraft[] | null = null;
|
||||
private aircraft: Aircraft[] = [];
|
||||
private credits: string[] = [];
|
||||
private nextFetchAt = 0;
|
||||
private fetching = false;
|
||||
private inFlight: AbortController | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly get: <T>(path: string) => Promise<T | null>,
|
||||
private readonly get: Get,
|
||||
private readonly region: SkyRegion,
|
||||
fallbackRoutes: SimRoute[],
|
||||
) {
|
||||
this.fallback = new SimulatedFlights(fallbackRoutes);
|
||||
@@ -284,17 +742,52 @@ class HttpFlights implements FlightSource {
|
||||
|
||||
poll(): Aircraft[] {
|
||||
this.refreshIfStale();
|
||||
if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now());
|
||||
if (this.live) return this.live;
|
||||
const { mode, plan, planPhase } = this;
|
||||
if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now());
|
||||
if (this.mode === "live") return this.aircraft;
|
||||
return this.fallback.poll();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own simulated plan is not live traffic and does not say it is.
|
||||
* It is a better simulation than the local one — every viewer agrees about
|
||||
* where the aircraft are — but nobody observed any of it, and `sources.flights`
|
||||
* on `/health` calls it `sim` for the same reason.
|
||||
*/
|
||||
live(): boolean {
|
||||
return this.mode === "live";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only while the body they came with is what is on screen. Falling back to
|
||||
* the simulator drops them, because the simulator's aircraft are this repo's
|
||||
* invention and crediting adsb.lol for them would be worse than crediting
|
||||
* nobody.
|
||||
*/
|
||||
attribution(): string[] {
|
||||
return this.mode === "fallback" ? [] : this.credits;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopped = true;
|
||||
this.inFlight?.abort();
|
||||
this.inFlight = null;
|
||||
}
|
||||
|
||||
private refreshIfStale(): void {
|
||||
const now = Date.now();
|
||||
if (this.fetching || now < this.nextFetchAt) return;
|
||||
this.fetching = true;
|
||||
void this.get<FlightsBody>("/flights")
|
||||
if (this.stopped || this.inFlight || now < this.nextFetchAt) return;
|
||||
const controller = new AbortController();
|
||||
this.inFlight = controller;
|
||||
void this.get<FlightsBody>("/flights", {
|
||||
query: { ...whereQuery(this.region.center), radiusNm: Math.round(this.region.radiusNm) },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((body) => {
|
||||
// Disposed while the request was in the air: the city has changed, this
|
||||
// object is nobody's traffic source any more, and a late answer must
|
||||
// not restart its clock or write to its state.
|
||||
if (this.stopped) return;
|
||||
if (!body) {
|
||||
// Hold whatever was already in hand rather than reverting to the
|
||||
// simulator: a deployment that has been showing real traffic for an
|
||||
@@ -303,22 +796,129 @@ class HttpFlights implements FlightSource {
|
||||
this.nextFetchAt = now + RETRY_SECONDS * 1000;
|
||||
return;
|
||||
}
|
||||
if (body.mode === "plan") {
|
||||
this.plan = body;
|
||||
this.planPhase = phasesFor(body);
|
||||
this.live = null;
|
||||
} else {
|
||||
this.live = body.aircraft;
|
||||
this.plan = null;
|
||||
}
|
||||
this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000;
|
||||
this.nextFetchAt = now + this.adopt(body) * 1000;
|
||||
})
|
||||
/**
|
||||
* The clock gets set whatever happens, and this is the branch that says
|
||||
* so for the case nobody plans for.
|
||||
*
|
||||
* `get` swallows every network failure already, so the only way here is a
|
||||
* body that broke `adopt` — which is exactly what used to happen, and
|
||||
* what it used to do was leave `nextFetchAt` at 0 and become an unhandled
|
||||
* rejection. A `.finally` without a `.catch` clears `inFlight` and
|
||||
* restores nothing, so the next `poll()` starts another fetch, and
|
||||
* `poll()` runs at `interval` seconds: one `/flights` request per second
|
||||
* per open tab, indefinitely, off one malformed response.
|
||||
*/
|
||||
.catch(() => {
|
||||
if (this.stopped) return;
|
||||
this.nextFetchAt = now + RETRY_SECONDS * 1000;
|
||||
})
|
||||
.finally(() => {
|
||||
this.fetching = false;
|
||||
if (this.inFlight === controller) this.inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the body if it is about this region, and say how long to wait next.
|
||||
*
|
||||
* **Everything read off the body is checked first**, which `markers()` does a
|
||||
* few hundred lines up and this did not. `Math.max(1, body.ttlSeconds)` on an
|
||||
* absent `ttlSeconds` is `NaN`, `nextFetchAt` becomes `NaN`, and
|
||||
* `now < this.nextFetchAt` is false forever — the poll interval quietly
|
||||
* becomes the frame rate. `body.aircraft.filter(...)` on an absent array
|
||||
* throws, which took the same route by a different door. Both were reachable
|
||||
* from a 200 with valid JSON in it, which is what a server one version behind
|
||||
* this one sends.
|
||||
*
|
||||
* A body that fails these is not this region's traffic and is treated as the
|
||||
* failure it is: the simulator, and ask again in `RETRY_SECONDS`.
|
||||
*/
|
||||
private adopt(body: FlightsBody): number {
|
||||
const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS;
|
||||
this.credits = [];
|
||||
|
||||
if (body.mode === "plan") {
|
||||
if (!Array.isArray(body.routes)) {
|
||||
this.mode = "fallback";
|
||||
this.plan = null;
|
||||
return RETRY_SECONDS;
|
||||
}
|
||||
// Phases are drawn over the *whole* plan and then filtered alongside the
|
||||
// routes, never over the surviving subset. The seed is what makes two
|
||||
// browsers agree about where the aircraft are, and it only does that if a
|
||||
// given route draws the same number wherever it is looked at — filter
|
||||
// first and a viewer of a two-city plan disagrees with a viewer of the
|
||||
// one-city plan the same server would serve tomorrow.
|
||||
const phases = phasesFor(body);
|
||||
const routes: FlightsPlanBody["routes"] = [];
|
||||
const planPhase: number[] = [];
|
||||
body.routes.forEach((route, i) => {
|
||||
// A leg counts as ours if either end is anywhere near the board — an
|
||||
// arrival begins a long way outside it, which is most of the point of
|
||||
// drawing traffic at all.
|
||||
const mine =
|
||||
inRegion(this.region, route.from[0], route.from[1], PLAN_SLACK_NM) ||
|
||||
inRegion(this.region, route.to[0], route.to[1], PLAN_SLACK_NM);
|
||||
if (!mine) return;
|
||||
routes.push(route);
|
||||
planPhase.push(phases[i] ?? 0);
|
||||
});
|
||||
if (routes.length === 0) {
|
||||
this.mode = "fallback";
|
||||
this.plan = null;
|
||||
return ELSEWHERE_SECONDS;
|
||||
}
|
||||
// Only the legs that are here, so a server serving one plan for several
|
||||
// metros does not put the other cities' aircraft off the edge of this one.
|
||||
this.plan = { ...body, routes };
|
||||
this.planPhase = planPhase;
|
||||
this.mode = "plan";
|
||||
return ttl;
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.aircraft)) {
|
||||
this.mode = "fallback";
|
||||
this.plan = null;
|
||||
return RETRY_SECONDS;
|
||||
}
|
||||
const here = body.aircraft.filter((a) => inRegion(this.region, a.lat, a.lng, LIVE_SLACK_NM));
|
||||
// An empty feed and a feed about somewhere else look the same after
|
||||
// filtering and are not the same thing. Three in the morning over a small
|
||||
// city really is an empty sky and should be drawn as one; a feed with fifty
|
||||
// aircraft in it and not one of them within a hundred miles of the board is
|
||||
// a server pointed at another city, and there the simulator is the honest
|
||||
// picture.
|
||||
if (here.length === 0 && body.aircraft.length > 0) {
|
||||
this.mode = "fallback";
|
||||
return ELSEWHERE_SECONDS;
|
||||
}
|
||||
this.aircraft = here;
|
||||
this.plan = null;
|
||||
this.mode = "live";
|
||||
// Only the live body carries credits — `wire.ts` puts `attribution` on
|
||||
// `FlightsLiveBody` and not on the plan, because the plan is this project's
|
||||
// own arithmetic and there is nobody to thank for it. Filtered rather than
|
||||
// trusted for the same reason as everything else in this method.
|
||||
this.credits = Array.isArray(body.attribution)
|
||||
? body.attribution.filter((line): line is string => typeof line === "string")
|
||||
: [];
|
||||
return ttl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack on the region tests, in nautical miles.
|
||||
*
|
||||
* Generous on the plan, because a plan is checked once and rejecting it wrongly
|
||||
* costs the deployment its whole shared sky for fifteen minutes. Tighter on
|
||||
* live positions, where the test also does duty as the filter that keeps
|
||||
* aircraft from being drawn off the edge of the board, and where a wrong answer
|
||||
* costs one dart for one poll.
|
||||
*/
|
||||
const PLAN_SLACK_NM = 120;
|
||||
const LIVE_SLACK_NM = 30;
|
||||
|
||||
/**
|
||||
* The per-route phase offsets, from the seed the server sent.
|
||||
*
|
||||
@@ -337,3 +937,53 @@ function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Ai
|
||||
// there so the server can build one without importing three.js.
|
||||
return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0)));
|
||||
}
|
||||
|
||||
// ---- Saying which -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Which of the three feeds on screen are real, right now.
|
||||
*
|
||||
* Three booleans rather than one because they are genuinely independent: the
|
||||
* markers come from a file the operator wrote, the weather from a government
|
||||
* API, the traffic from a receiver on somebody's roof, and every combination of
|
||||
* the three is a deployment that exists. A single flag has to pick one of them
|
||||
* to be about and then lie about the other two.
|
||||
*/
|
||||
export interface Liveness {
|
||||
/** Markers came from the API rather than from `sample.ts`. */
|
||||
markers: boolean;
|
||||
/** The sky is a real observation, of this city, recent enough to mean it. */
|
||||
weather: boolean;
|
||||
/** The aircraft are observed positions, in this city's region. */
|
||||
flights: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The corner label, derived from what is actually live.
|
||||
*
|
||||
* This used to be one boolean and the boolean was the markers feed, so a
|
||||
* deployment with a real ADS-B receiver and a real weather station but no
|
||||
* marker file said nothing at all, and a deployment with a marker file and
|
||||
* neither of the other two claimed the lot. Both are wrong the same way. The
|
||||
* label sits in the corner of the whole map, so it is read as a claim about the
|
||||
* whole map, and the only claim that can be made about the whole map is one
|
||||
* that is true of all of it.
|
||||
*
|
||||
* Hence three cases. Nothing live is **silence**, which is `renderLegend`'s
|
||||
* existing rule and the right one: a caption that is on screen always is
|
||||
* furniture nobody reads, and the fabricated-data disclosure has a better home
|
||||
* on the boot card and in the `?` card, where it is read once and reachable
|
||||
* forever. Some of it live **names the parts**, because "live data" over
|
||||
* invented companies is exactly the lie the `live` flag was introduced to
|
||||
* prevent, and "live weather" over invented companies is not. All three live is
|
||||
* the only case that earns the unqualified claim.
|
||||
*/
|
||||
export function describeLiveness(live: Liveness): string {
|
||||
const parts: string[] = [];
|
||||
if (live.weather) parts.push("weather");
|
||||
if (live.flights) parts.push("traffic");
|
||||
if (live.markers) parts.push("markers");
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 3) return "live data";
|
||||
return `live ${parts.join(" + ")}`;
|
||||
}
|
||||
|
||||
+52
-3
@@ -27,8 +27,8 @@
|
||||
* without data that exercises it.
|
||||
*/
|
||||
|
||||
import type { SimRoute } from "../engine/flights.ts";
|
||||
import type { Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { regionOf, syntheticRoutes, type SimRoute } from "../engine/flights.ts";
|
||||
import type { City, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* 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.
|
||||
*
|
||||
* 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[] = [
|
||||
{ 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: "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
@@ -131,8 +131,26 @@ export function createBlocks(world: World): THREE.InstancedMesh {
|
||||
|
||||
const [lat, lng] = world.unproject(x, z);
|
||||
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
|
||||
|
||||
// Cubed, so tall buildings stay rare and the skyline keeps a
|
||||
|
||||
+190
-7
@@ -16,7 +16,7 @@
|
||||
|
||||
import * as THREE from "three";
|
||||
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";
|
||||
|
||||
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
|
||||
@@ -31,6 +31,156 @@ export interface SimRoute {
|
||||
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 east–west 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
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -93,23 +255,36 @@ function nowSeconds(): number {
|
||||
* 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:
|
||||
* 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 {
|
||||
readonly interval = 8;
|
||||
private held: Aircraft[] = [];
|
||||
private heldAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly radiusNm = 25,
|
||||
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
|
||||
private readonly region: SkyRegion,
|
||||
) {}
|
||||
|
||||
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 {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
if (!res.ok) return this.hold();
|
||||
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")
|
||||
// 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) => ({
|
||||
id: a.hex ?? `${a.flight ?? "?"}`,
|
||||
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,
|
||||
heading: typeof a.track === "number" ? a.track : 0,
|
||||
}));
|
||||
this.heldAt = nowSeconds();
|
||||
return this.held;
|
||||
} catch {
|
||||
// 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 {
|
||||
|
||||
+89
-16
@@ -8,13 +8,23 @@
|
||||
* one renderer serve a private map coloured by pipeline state and a public one
|
||||
* coloured by sector without either being a fork.
|
||||
*
|
||||
* The renderer and the loop live in `Stage`; 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 its heightfield — for
|
||||
* the Bay Area, about 2.3 s — on the way back. See CONTRACT.md §1.
|
||||
* The renderer and the loop live in `Stage`, which is **handed in and not
|
||||
* built here**: one renderer serves the canvas for the life of the page, and a
|
||||
* city is a thing that is put on it and taken off again. Building a Stage per
|
||||
* city is what this function used to do, and `stage.ts` records what it cost —
|
||||
* every switch orphaned a 2048² shadow map on the GL context, because
|
||||
* `WebGLRenderer.dispose()` frees none of a renderer's own textures.
|
||||
*
|
||||
* 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";
|
||||
@@ -23,7 +33,7 @@ import { createNightLights, type NightLights } from "./nightlights.ts";
|
||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.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 { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
@@ -35,7 +45,7 @@ import type {
|
||||
MarkerPalette,
|
||||
ScenePalette,
|
||||
} from "./types.ts";
|
||||
import { World } from "./world.ts";
|
||||
import { World, type FieldProgress } from "./world.ts";
|
||||
|
||||
export interface SceneOptions {
|
||||
city: City;
|
||||
@@ -49,15 +59,32 @@ export interface SceneOptions {
|
||||
* until somebody wires up the sun is not a scene that boots with no config.
|
||||
*/
|
||||
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 {
|
||||
world: World;
|
||||
chapters: Chapter[];
|
||||
/**
|
||||
* The renderer and the loop. An office is swapped in with
|
||||
* `stage.setScene(officeScene)` and this city back in the same way; the one
|
||||
* that steps out is paused, not thrown away.
|
||||
* The stage this city was built on, which it uses and does not own. An office
|
||||
* is swapped in with `stage.setScene(officeScene)` and this city back in the
|
||||
* 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;
|
||||
/** This city, as the thing `stage.setScene` takes. */
|
||||
@@ -74,15 +101,45 @@ export interface SceneHandle {
|
||||
current(): string;
|
||||
onChapterChange(fn: (id: string) => void): void;
|
||||
setMarkers(markers: Marker[]): void;
|
||||
/** Take this city off the stage and release everything it built. */
|
||||
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 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 stage = createStage(canvas);
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
/**
|
||||
@@ -238,8 +295,24 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
||||
markerLayer.setMarkers(markers);
|
||||
},
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
+249
-7
@@ -12,12 +12,46 @@
|
||||
* 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
|
||||
* 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 { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { deviceProfile } from "./stage.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. */
|
||||
export interface Pose {
|
||||
position: THREE.Vector3;
|
||||
@@ -101,16 +135,108 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
|
||||
const controls = new OrbitControls(camera, dom);
|
||||
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.minDistance = options.minDistance ?? 12;
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1);
|
||||
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.camera.near = options.shadowNear ?? 10;
|
||||
sun.shadow.camera.far = options.shadowFar ?? 520;
|
||||
@@ -175,6 +301,19 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
let flying = false;
|
||||
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) {
|
||||
flying = false;
|
||||
camera.position.copy(pose.position);
|
||||
@@ -182,7 +321,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
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) {
|
||||
if (reducedMotion) {
|
||||
setPose(pose);
|
||||
return;
|
||||
}
|
||||
from.position.copy(camera.position);
|
||||
from.target.copy(controls.target);
|
||||
to.position.copy(pose.position);
|
||||
@@ -203,14 +358,68 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
// thrown away.
|
||||
let pointerDirty = false;
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
function aimAt(clientX: number, clientY: number) {
|
||||
const rect = dom.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
pointerDirty = false;
|
||||
if (picked === null) return;
|
||||
@@ -219,7 +428,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
dom.style.cursor = "";
|
||||
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() {
|
||||
if (!picking || !pointerDirty) return;
|
||||
@@ -253,6 +470,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
},
|
||||
resetPick,
|
||||
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) {
|
||||
flightT = Math.min(1, flightT + dt * flightSpeed);
|
||||
// easeInOutCubic — a flight that starts and lands gently
|
||||
@@ -268,7 +502,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
},
|
||||
dispose() {
|
||||
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 = "";
|
||||
picking = null;
|
||||
controls.dispose();
|
||||
|
||||
+166
-4
@@ -14,6 +14,32 @@
|
||||
* rebuild on the way back out. Stage therefore disposes nothing it did not
|
||||
* create — whoever built a `StageScene` disposes it, when they actually mean
|
||||
* 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";
|
||||
@@ -31,25 +57,155 @@ export interface StageScene {
|
||||
|
||||
export interface Stage {
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export interface StageOptions {
|
||||
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;
|
||||
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 {
|
||||
const profile = deviceProfile();
|
||||
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);
|
||||
if (options.shadows ?? 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;
|
||||
@@ -62,6 +218,11 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
|
||||
// Compared against CSS pixels, because `canvas.width` is in device pixels and
|
||||
// differs from `clientWidth` on every retina display — checking it would call
|
||||
// `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 lastHeight = 0;
|
||||
|
||||
@@ -111,6 +272,7 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
|
||||
if (s === currentScene) return;
|
||||
currentScene?.onExit?.();
|
||||
currentScene = s;
|
||||
if (!s) return;
|
||||
// The incoming camera may never have seen this canvas, and the canvas may
|
||||
// have been resized while the scene was paused.
|
||||
applyViewport(s);
|
||||
|
||||
+11
-6
@@ -46,16 +46,21 @@ export function paletteFor(world: World): ScenePalette {
|
||||
* 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
|
||||
* 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(
|
||||
world: World,
|
||||
pal: ScenePalette,
|
||||
scratch: THREE.Color,
|
||||
lat: number,
|
||||
lng: number,
|
||||
inPark: boolean,
|
||||
elevation: number,
|
||||
): THREE.Color {
|
||||
if (world.pointInAny(lat, lng, world.city.parks)) {
|
||||
if (inPark) {
|
||||
return scratch
|
||||
.setHex(pal.park)
|
||||
.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 {
|
||||
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 colors: number[] = [];
|
||||
@@ -130,7 +135,7 @@ export function createTerrain(world: World): THREE.Mesh {
|
||||
const e = height[k] ?? 0;
|
||||
const [x, z] = world.project(lat, lng);
|
||||
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);
|
||||
const id = positions.length / 3 - 1;
|
||||
vertexAt[k] = id;
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
+465
-70
@@ -5,10 +5,89 @@
|
||||
* 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
|
||||
* 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";
|
||||
|
||||
/** 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 {
|
||||
readonly city: City;
|
||||
readonly lngScale: number;
|
||||
@@ -18,12 +97,8 @@ export class World {
|
||||
readonly lngSquash: number;
|
||||
|
||||
private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
|
||||
private field: Float32Array | null = null;
|
||||
private fieldLand: Uint8Array | null = null;
|
||||
private lats: Float64Array | null = null;
|
||||
private lngs: Float64Array | null = null;
|
||||
private latSteps = 0;
|
||||
private lngSteps = 0;
|
||||
private state: Field | null = null;
|
||||
private pending: Promise<boolean> | null = null;
|
||||
|
||||
constructor(city: City) {
|
||||
this.city = city;
|
||||
@@ -152,6 +227,11 @@ export class World {
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -205,55 +285,102 @@ export class World {
|
||||
|
||||
// ---- 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
|
||||
* 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.
|
||||
* Build the heightfield, off the main thread if the browser will let us.
|
||||
*
|
||||
* Resolves `true` when the field is up and the synchronous samplers are safe,
|
||||
* `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 } {
|
||||
if (this.field && this.fieldLand && this.lats && this.lngs) {
|
||||
return { height: this.field, land: this.fieldLand };
|
||||
}
|
||||
const { bounds, cellLat, cellLng } = this.city;
|
||||
const coarse = Math.max(1, this.city.coarseFactor ?? 1);
|
||||
const regions = this.city.focusRegions ?? [];
|
||||
ready(options: ReadyOptions = {}): Promise<boolean> {
|
||||
if (this.state) return Promise.resolve(true);
|
||||
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;
|
||||
}
|
||||
|
||||
// Rectilinear but NOT uniform: fine spacing across any band that a focus
|
||||
// region occupies, coarse everywhere else. See `buildAxis`.
|
||||
this.lats = buildAxis(
|
||||
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]),
|
||||
);
|
||||
private async build(options: ReadyOptions): Promise<boolean> {
|
||||
const { signal, onProgress } = options;
|
||||
if (signal?.aborted) return false;
|
||||
|
||||
this.latSteps = this.lats.length - 1;
|
||||
this.lngSteps = this.lngs.length - 1;
|
||||
const w = this.lngSteps + 1;
|
||||
const height = new Float32Array((this.latSteps + 1) * w);
|
||||
const land = new Uint8Array((this.latSteps + 1) * w);
|
||||
for (let i = 0; i <= this.latSteps; i++) {
|
||||
const lat = this.lats[i] as number;
|
||||
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;
|
||||
const worker = spawnFieldWorker();
|
||||
if (worker) {
|
||||
const result = await runInWorker(worker, this.city, signal, onProgress);
|
||||
if (result === "abandoned") return false;
|
||||
if (result !== "failed") {
|
||||
this.adopt(result);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this.field = height;
|
||||
this.fieldLand = land;
|
||||
return { height, land };
|
||||
|
||||
if (signal?.aborted) return false;
|
||||
// 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
|
||||
* `minLat + i * cellLat` any more.
|
||||
*/
|
||||
lattice(): {
|
||||
latSteps: number;
|
||||
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,
|
||||
};
|
||||
lattice(): Field {
|
||||
return this.ensureField();
|
||||
}
|
||||
|
||||
/** Elevation in metres, bilinearly sampled from the cached lattice. */
|
||||
elevationSampled(lat: number, lng: number): number {
|
||||
const { height } = this.buildField();
|
||||
const lats = this.lats as Float64Array;
|
||||
const lngs = this.lngs as Float64Array;
|
||||
const w = this.lngSteps + 1;
|
||||
const { lats, lngs, lngSteps, height } = this.ensureField();
|
||||
const w = lngSteps + 1;
|
||||
|
||||
const i = cellIndex(lats, lat);
|
||||
const j = cellIndex(lngs, lng);
|
||||
@@ -311,6 +421,291 @@ export class World {
|
||||
groundAt(lat: number, lng: number): number {
|
||||
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 ------------------------------------------
|
||||
|
||||
+821
-105
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
@@ -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; }
|
||||
`;
|
||||
Reference in New Issue
Block a user