1
0

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

Three things that were built and never connected, connected.

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

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

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

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

Two blockers the review caught:

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+72 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 eastwest offset in nautical miles is more of them.
const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180);
const routes: SimRoute[] = [];
for (let i = 0; i < count; i++) {
const bearing = rand() * Math.PI * 2;
// Push the chord off the centre so the legs are not six spokes through
// downtown. ±60% of the radius crosses the board at a spread of depths.
const offset = (rand() * 1.2 - 0.6) * region.radiusNm;
const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1));
const alongE = Math.sin(bearing);
const alongN = Math.cos(bearing);
const from = {
lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm,
};
const to = {
lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm,
};
// A third arriving, a third departing, a third crossing high. A board where
// everything is at cruise has no altitude ramp to read and no reason for
// the colour band in `createFlightLayer` to exist.
const kind = i % 3;
const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800;
const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400;
// Eight seconds a nautical mile is about 450 knots, which is an airliner.
const duration = Math.round(half * 2 * 8);
routes.push({
callsign: `SIM ${i + 1}`,
from: [from.lat, from.lng],
to: [to.lat, to.lng],
fromAlt: Math.round(fromAlt),
toAlt: Math.round(toAlt),
duration,
});
}
return routes;
}
/**
* Traffic that behaves like the real thing without being it: aircraft move
* 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -46,16 +46,21 @@ export function paletteFor(world: World): ScenePalette {
* and McLaren, all of which are parks and get their green from being in
* `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;
+87
View File
@@ -0,0 +1,87 @@
/**
* The heightfield, built off the main thread.
*
* This worker exists for one number: on the Bay Area, producing the lattice is
* about 730 ms of unbroken synchronous work, and SoCal's is around 390 ms on
* top of a switch that already blocks for four seconds. Nothing paints and
* nothing responds while it runs — not the boot card, not the progress line
* explaining why the boot card is still up.
*
* It is deliberately thin. All the geography lives in `world.ts` and this file
* calls `computeField` exactly as the main thread would; the only thing here
* that is not in `world.ts` is the message plumbing. The alternative — a second
* copy of the sampling loop, tuned separately — produces two maps of the same
* city that differ in the fourth decimal place and agree on nothing that would
* make the difference visible.
*
* ## Why the `City` is sent whole
*
* A `City` is pure data by contract (`types.ts`), so structured clone carries it
* across as-is. That costs something — the Bay Area pack's polygons are a few
* hundred kilobytes — but it is paid once per city, against a field that comes
* back as several megabytes of transferred buffer. Sending a city *id* and
* importing the pack in here instead would drag both city modules into the
* worker chunk and break the rule that a pack is data the engine is handed,
* not data the engine knows about.
*/
import { World, computeField, type FieldMessage, type FieldRequest } from "./world.ts";
/**
* `DedicatedWorkerGlobalScope` is not in `lib.dom.d.ts` and this project's
* `tsconfig.json` belongs to another session, so the two members this file
* actually touches are declared here rather than by adding `WebWorker` to
* `lib`. Narrow on purpose: if this grows a third member, that is the moment to
* ask for the lib entry instead.
*/
declare const self: {
onmessage: ((event: MessageEvent<FieldRequest>) => void) | null;
postMessage(message: FieldMessage, transfer?: Transferable[]): void;
};
/**
* How often progress goes back over the wire.
*
* Per-row would be 656 messages for San Francisco, and every one of them is a
* task queued on the main thread — the thread this whole file exists to leave
* alone. Eight a second is enough for a bar that moves and cheap enough to be
* invisible.
*/
const PROGRESS_INTERVAL_MS = 125;
self.onmessage = (event: MessageEvent<FieldRequest>) => {
const { city } = event.data;
try {
const world = new World(city);
let last = 0;
const field = computeField(world, (done, rows) => {
const now = performance.now();
if (done < rows && now - last < PROGRESS_INTERVAL_MS) return;
last = now;
self.postMessage({ type: "progress", done, rows });
});
// Transfer, do not copy. The Bay Area's field is a 2.1 MB `Float32Array`
// and two 533 kB `Uint8Array`s, plus the two axes; structured-cloning that
// back hands the main thread a memcpy and an allocation of everything the
// worker just saved it. After this the worker's own views are detached,
// which is fine because it is about to be terminated.
//
// Every buffer in the field is listed. A buffer left off this list is
// silently *copied* instead of moved, which is invisible in behaviour and
// is exactly the cost this postMessage exists to avoid.
self.postMessage({ type: "field", ...field }, [
field.lats.buffer,
field.lngs.buffer,
field.height.buffer,
field.land.buffer,
field.park.buffer,
]);
} catch (err) {
// Report rather than throw. An uncaught error in here reaches the main
// thread as an `ErrorEvent` with no message under most cross-origin rules,
// and "something went wrong somewhere" is not worth the fallback path being
// silent about.
self.postMessage({ type: "failed", message: err instanceof Error ? err.message : String(err) });
}
};
+465 -70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1830
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
/**
* `src/tools/` — the instruments. Everything in here is for the person building
* this map, not for the person looking at it.
*
* There is one rule and this file exists to hold it: **nothing under
* `src/tools/` may be reached by a static import from the app.** `main.ts` loads
* it like this, and only like this:
*
* ```ts
* if (access.can.debug) {
* const { createGodmode } = await import("./tools/index.ts");
* godmode = createGodmode({ ... });
* }
* ```
*
* A static `import` would put the panel in the entry chunk, and the entry chunk
* is 772 kB before anybody has done anything — the whole thing arrives, parses
* and runs for every anonymous visitor who will never see a single control in
* it. Behind a dynamic import Vite gives the tools a chunk of their own, and a
* visitor who is not god does not download the code at all. That is also the
* strongest available reading of "nothing here may run for a non-god visitor":
* not a hidden panel, not a disabled panel, no panel.
*
* The corollary is that the arrow points one way. A tool may import from
* `engine/`, `access.ts` or `adapters/`; nothing outside `src/tools/` may import
* from inside it except through an `await import()`, or the split silently stops
* happening and nobody notices until the bundle is measured again.
*
* Type-only imports are the exception and are free: `import type { Godmode }` is
* erased at build time and creates no chunk edge. `main.ts` needs one to hold
* the handle in a nullable field.
*/
export { createGodmode } from "./godmode.ts";
export type { Godmode, GodmodeOptions, GodmodePlace } from "./godmode.ts";
+958
View File
@@ -0,0 +1,958 @@
/**
* Fly the camera somewhere, like what you see, and get back the five numbers
* that put it there.
*
* Adding a city to this repo means hand-authoring `chapters: Chapter[]`, and
* every chapter carries a `focus` — `{ lat, lng, distance, height, rotation }`
* — which is only knowable by flying somewhere, liking the frame, and then
* working out what the numbers were. The loop that produced the twelve poses in
* `cities/sf.ts` was: guess five numbers, rebuild, look, guess again. New York
* is on the roadmap and it needs a dozen more of them.
*
* ## This is the inverse of `chapterPose`, and the round trip is measured
*
* `scene.ts` turns a focus into a camera with
*
* target = (x, groundAt(lat,lng), z) where [x,z] = project(lat,lng)
* position = target + (sin(rot)·distance, height, cos(rot)·distance)
*
* which is invertible in one direction and *not* in the other, and the part
* that is not is the whole reason this file measures itself. Going backwards:
* `lat`/`lng` come from `unproject` of the orbit target's x and z, `distance`
* and `rotation` are the polar form of the horizontal offset from target to
* camera, and `height` is the camera's y above the ground under the target. The
* camera position comes back exactly. The *target* does not, because
* `chapterPose` pins target.y to the ground and OrbitControls does not: pan the
* view and the target lifts off the terrain, and no chapter can express that.
* The panel therefore shows the lift and the aim error it causes rather than
* quietly emitting a pose that frames something else. See `measure`.
*
* ## The maths is duplicated from `scene.ts` on purpose, and it is a liability
*
* `chapterPose` is a closure inside `createScene` and is not exported, so
* `poseOf` below is a copy of it. That is the one thing in this file that can
* rot silently: change the pose convention in `scene.ts` and this tool will go
* on confidently emitting the old one. The fix is for `scene.ts` to export the
* conversion and for this file to import it; until then, the two blocks are
* written to look identical so a diff between them is obvious.
*
* ## Precision: five decimals of degree, two of unit, five of radian
*
* The point of the tool is a block you can paste, so the numbers have to be
* short enough to read and long enough to reproduce the frame. Measured over
* every authored chapter in both packs plus eight jittered poses around each,
* worst case:
*
* - **What the packs carry today** (4 dp of degree, integer distance and
* height, 2 dp of radian, all typed by hand from a map): the camera lands
* 265 m from where it was on the Bay Area board and 466 m out on SoCal, and
* the view direction is 0.7°–1.2° off — about thirty pixels across a
* 1600-pixel frame. Fine for a pose a human invented at those digits;
* useless for reproducing one a human found by flying.
* - **This scheme**: camera position within 0.0066 scene units (0.62 m) on
* the Bay Area and 0.0063 units (2.44 m) on SoCal, orbit target within
* 0.74 m and 0.89 m, and the aim within 0.0098° — 0.024% of a 42° frame,
* which is a third of a pixel at 1600 wide.
*
* Going finer buys nothing anyone can see and costs a digit in a file people
* read. Trailing zeros are trimmed, so a pose that happens to be round emits
* `rotation: 0.4`, exactly as `sf.ts` already has it.
*
* **The order of the quantisation is load-bearing.** Round `lat`/`lng` *first*,
* then solve `distance`, `height` and `rotation` against the ground under the
* rounded point. The obvious order — take all five numbers off the live camera,
* round all five — feeds the terrain's own slope into the camera: `height` is
* measured from the ground under the exact target and re-applied over the
* ground under the rounded one, and with `verticalExaggeration` at 3.6 a metre
* of horizontal rounding on the side of Twin Peaks is several units of altitude.
* Measured, that order costs 0.98 m instead of 0.62 m at these digits, and
* 7.8 m instead of 3.0 m at the packs' four decimals.
*
* ## Shape
*
* Same self-contained imperative handle as `engine/minimap.ts`, and the same
* two rules: the caller supplies a container and owns where it goes, and
* `tick()` runs inside a frame loop so it compares a timestamp and eight
* numbers and returns. Unlike the minimap it never writes to the camera at all
* — the only way this tool moves anything is by handing a `Pose` to the kit's
* own `flyTo`.
*
* God tier only. `access.ts` is clear that a browser-side check is theatre
* against anyone with a console, so the gate here is not security; it is a
* loaded gun pointed away from the ninety-nine percent of sessions that have no
* business seeing an authoring instrument at all.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { Pose } from "../engine/scenekit.ts";
import type { Chapter } from "../engine/types.ts";
import type { World } from "../engine/world.ts";
export interface PoseEditorOptions {
/**
* Where the panel mounts. The tool appends exactly one element to it and
* owns everything below that; the caller owns the box, its position and its
* size.
*/
container: HTMLElement;
/**
* Pass `access.can.debug`. Construction throws when it is false — see the
* file header for why that is a thrown programming error and not a hidden
* no-op.
*/
allowed: boolean;
/** The same `World` the scene is drawing. `groundAt` must agree, or nothing does. */
world: World;
/** The live scene camera. Read every tick, written never. */
camera: THREE.PerspectiveCamera;
/** The live orbit controls. `controls.target` is the pose's target. */
controls: OrbitControls;
/**
* The kit's flight, `SceneKit.flyTo`. The only channel through which this
* tool is allowed to move the camera, and the reason it takes a callback
* rather than the controls: a tool that wrote `camera.position` directly
* would be a second thing with opinions about where the camera is, which is
* the failure CONTRACT.md §1 splits `Stage` and `SceneKit` to avoid.
*/
flyTo(pose: Pose): void;
/**
* `SceneKit.flying`. Capture is refused mid-flight: a frame sampled halfway
* through an eased interpolation is a pose nobody chose, and it looks
* plausible enough in the readout to get pasted.
*/
flying?(): boolean;
/**
* The chapters already in the pack, for the starting number and for the
* duplicate-id warning. `chapterById` in `scene.ts` is built from an object
* literal, so two chapters sharing an id means one of them silently is not
* in the tour.
*/
existingChapters?: readonly Chapter[];
}
/** How far the emitted block lands from the camera it was taken off. */
export interface PoseResidual {
/** Camera position error, in scene units. */
position: number;
/** Orbit target error, in scene units. Carries the ground lock as well as rounding. */
target: number;
/** Angle between the live view direction and the emitted one, in degrees. */
aim: number;
/** `aim` as a fraction of the camera's vertical field of view. */
frame: number;
/**
* How far the live orbit target floats above the terrain under it, in scene
* units. Measured against the ground under the *unrounded* target, so it is
* a statement about the camera and not about the emission precision: it is
* non-zero exactly when the view has been panned, and that is the one part
* of a pose a `Chapter` cannot carry.
*/
lift: number;
}
export interface CapturedPose {
chapter: Chapter;
residual: PoseResidual;
}
export interface PoseEditor {
/** Read the live camera, append a chapter to the session list, select it. */
capture(): CapturedPose | null;
poses(): readonly CapturedPose[];
/** The whole list as a paste-ready `chapters` array. */
code(): string;
/** Call from the frame loop. Cheap by construction; see `tick`. */
tick(): void;
setVisible(visible: boolean): void;
destroy(): void;
}
// ---- Precision --------------------------------------------------------------
const LATLNG_DP = 5;
const SPAN_DP = 2;
const ROT_DP = 5;
const TAU = Math.PI * 2;
/** Where the packs wrap. Not enforced by a formatter in this repo; matched by eye. */
const COLUMNS = 100;
/** Live readout ceiling. The stage runs at 60 and none of these digits need it. */
const FRAME_MS = 120;
/**
* Aim error, in degrees, above which the panel stops calling a pose clean.
* A tenth of a degree is four pixels across a 1600-pixel frame — under it
* nothing on screen moves, over it the pasted chapter is framing something
* slightly different from what was approved.
*/
const AIM_WARN = 0.1;
export function createPoseEditor(options: PoseEditorOptions): PoseEditor {
if (!options.allowed) {
throw new Error("poseEditor is a god-tier instrument and was constructed without the tier");
}
const { world, camera, controls } = options;
const existing = options.existingChapters ?? [];
// ---- The conversion -------------------------------------------------------
/**
* A copy of `chapterPose` in `engine/scene.ts`. Kept character-for-character
* where it can be, so that a diff between the two files reads as a diff. See
* the file header: this duplication is the one thing here that can rot.
*
* It writes into scratch vectors rather than allocating, because `measure`
* calls it from the frame loop. `SceneKit.flyTo` copies out of the pose it is
* given, so handing it the scratch is safe — and if that ever stops being
* true this is where it breaks.
*/
const scratch: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
function poseOf(focus: Chapter["focus"], into: Pose = scratch): Pose {
const [x, z] = world.project(focus.lat, focus.lng);
const groundY = world.groundAt(focus.lat, focus.lng);
into.target.set(x, groundY, z);
into.position.set(
x + Math.sin(focus.rotation) * focus.distance,
groundY + focus.height,
z + Math.cos(focus.rotation) * focus.distance,
);
return into;
}
/**
* The live camera, as a `Chapter["focus"]`, already at emission precision.
*
* Quantised in the order the file header argues for: the target first,
* everything else against where the target landed. The bearing convention is
* `chapterPose`'s — `atan2(dx, dz)`, so zero is due south of the target and
* the angle opens toward the east, which is not the compass bearing anyone
* expects and is what the packs are already written in.
*/
function readFocus(): Chapter["focus"] {
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
const lat = round(rawLat, LATLNG_DP);
const lng = round(rawLng, LATLNG_DP);
const [x, z] = world.project(lat, lng);
const groundY = world.groundAt(lat, lng);
const dx = camera.position.x - x;
const dz = camera.position.z - z;
const distance = Math.hypot(dx, dz);
// Directly overhead the bearing is undefined, and `atan2` does not say so:
// on a pair of negative zeros it answers -π, which the wrap below turns
// into a confident π. The pose still round-trips either way — sin and cos
// of anything times a zero distance is a zero offset — but
// `rotation: 3.14159, distance: 0` in a city pack is a riddle, so straight
// down is written as zero.
let rotation = distance < 1e-6 ? 0 : Math.atan2(dx, dz);
if (rotation < 0) rotation += TAU;
rotation = round(rotation, ROT_DP);
// Rounding up through a full turn: a bearing a hair below due south comes
// out of the wrap as 6.283185…, which at five decimals is 6.28319, which is
// larger than a turn. Zero is the same pose and reads like one.
if (rotation >= TAU) rotation = 0;
return {
lat,
lng,
distance: round(distance, SPAN_DP),
height: round(camera.position.y - groundY, SPAN_DP),
rotation,
};
}
const probe: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
const liveDir = new THREE.Vector3();
const emitDir = new THREE.Vector3();
/**
* What the emitted block costs, against the camera it was taken off.
*
* This is the correctness claim of the whole tool and it is checked at
* capture rather than asserted in a comment: the focus goes back through the
* local copy of `chapterPose` and the two poses are differenced. Three
* separate numbers because they fail separately — rounding moves the camera,
* the ground lock moves the target, and only the angle between the two view
* vectors says whether any of it is visible.
*/
function measure(focus: Chapter["focus"]): PoseResidual {
poseOf(focus, probe);
liveDir.subVectors(controls.target, camera.position);
emitDir.subVectors(probe.target, probe.position);
const degrees =
liveDir.lengthSq() > 0 && emitDir.lengthSq() > 0
? (liveDir.angleTo(emitDir) * 180) / Math.PI
: 0;
// Against the ground under the live target rather than under the rounded
// one. The difference is sub-millimetre and it is still worth the second
// sample: `probe.target.y` folds the terrain's slope across a metre of
// rounding into a number the panel presents as "you panned", and a warning
// that fires on its own rounding is a warning people learn to ignore.
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
return {
position: probe.position.distanceTo(camera.position),
target: probe.target.distanceTo(controls.target),
aim: degrees,
frame: camera.fov > 0 ? degrees / camera.fov : 0,
lift: controls.target.y - world.groundAt(rawLat, rawLng),
};
}
// ---- The session list -----------------------------------------------------
interface Entry {
chapter: Chapter;
residual: PoseResidual;
/** False once the id has been typed, so a later label edit stops overwriting it. */
idAuto: boolean;
row: HTMLElement;
name: HTMLElement;
note: HTMLElement;
}
const entries: Entry[] = [];
let selected: Entry | null = null;
let visible = true;
let destroyed = false;
// ---- DOM ------------------------------------------------------------------
/**
* The panel lives in a shadow root.
*
* `index.html` is the entire stylesheet of this application and it belongs to
* whoever is integrating this tool, not to the tool. A shadow root is the
* only way to ship a widget with its own styling that neither reads from nor
* writes to that file. Custom properties still cross the boundary, which is
* the useful half of the isolation: `var(--amber)` below picks up the app's
* own accent when the panel is mounted inside it and falls back to the same
* literal when it is mounted anywhere else.
*/
const host = document.createElement("div");
host.className = "tera-pose-editor";
const root = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = CSS;
root.append(style);
const panel = el("div", "panel");
root.append(panel);
panel.append(el("div", "hd", "Chapter pose"));
// The live readout.
const live = el("div", "live");
const liveLatLng = el("div", "row mono");
const liveFocus = el("div", "row mono");
const liveMeta = el("div", "row sub");
const liveWarn = el("div", "warn");
liveWarn.hidden = true;
live.append(liveLatLng, liveFocus, liveMeta, liveWarn);
panel.append(live);
const captureBtn = el("button", "btn primary", "Capture pose") as HTMLButtonElement;
captureBtn.type = "button";
captureBtn.addEventListener("click", () => {
capture();
});
panel.append(captureBtn);
// The naming form. It edits whichever pose is selected rather than being a
// form you fill in before capturing: you find the frame first and work out
// what to call it second, which is the order the job actually happens in.
const form = el("div", "form");
const fId = field(form, "id", "kebab-case, unique in the pack");
const fNumber = field(form, "number", '"13"');
const fLabel = field(form, "label", "Mission Bay");
const fShort = field(form, "shortLabel", "Mission Bay");
const fDesc = area(form, "description", "A sentence about why it is on the map.");
panel.append(form);
const list = el("div", "list");
panel.append(list);
const actions = el("div", "actions");
const copyOne = el("button", "btn", "Copy chapter") as HTMLButtonElement;
const copyAll = el("button", "btn", "Copy all") as HTMLButtonElement;
copyOne.type = "button";
copyAll.type = "button";
copyOne.addEventListener("click", () => {
if (selected) void copy(emitChapter(selected.chapter));
});
copyAll.addEventListener("click", () => {
if (entries.length > 0) void copy(code());
});
actions.append(copyOne, copyAll);
panel.append(actions);
const msg = el("div", "msg");
panel.append(msg);
/**
* The clipboard fallback.
*
* `navigator.clipboard` needs a secure context, and a self-hoster running
* this off `http://` on a LAN address has none — which is a documented
* deployment in `deploy/STATIC.md`, not an edge case. So the failure path is
* a textarea with the text already selected, and the user presses their own
* copy key.
*
* `document.execCommand("copy")` is deliberately not tried in between. Inside
* a shadow root the selection it copies is not reliably the one you just
* made, and it reports success either way; a button that says "Copied" and
* copied nothing is worse than a button that says it could not.
*/
const out = el("textarea", "out") as HTMLTextAreaElement;
out.readOnly = true;
out.spellcheck = false;
out.hidden = true;
panel.append(out);
/**
* Keystrokes stop at the shadow boundary.
*
* `main.ts` binds the application's shortcuts to `window` and guards them by
* testing whether `event.target` is an input or a textarea. **That guard does
* not work through a shadow root**: the event is retargeted on its way out, so
* by the time it reaches `window` the target is this host `<div>` and the
* guard passes. Typing "Mission Bay" into the label field would fly the camera
* to chapter one on the "1", toggle the plan view on the "m" and open an
* office on the "o" — which is to say a tool whose entire premise is that it
* does not perturb the scene would be the only thing in the app that did.
*
* Everything is swallowed rather than only the keys that currently mean
* something, because the alternative is this list going stale the first time
* somebody adds a shortcut. Escape is the one that also does something here:
* it puts the clipboard fallback away.
*/
function containKeys(event: Event) {
event.stopPropagation();
if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
if (out.hidden) return;
out.hidden = true;
say("");
}
root.addEventListener("keydown", containKeys);
root.addEventListener("keyup", containKeys);
options.container.append(host);
// ---- Form wiring ----------------------------------------------------------
function bindField(input: HTMLInputElement | HTMLTextAreaElement, apply: (v: string) => void) {
input.addEventListener("input", () => {
if (!selected) return;
apply(input.value);
refreshRow(selected);
warnDuplicate();
});
}
bindField(fId, (v) => {
if (!selected) return;
selected.chapter.id = v;
// Typing an id takes it off the leash; a later label edit stops rewriting
// it. Clearing the field puts it back, which is the only way to undo that
// without a reset button nobody would find.
selected.idAuto = v.trim() === "";
if (selected.idAuto) selected.chapter.id = slug(selected.chapter.label);
});
bindField(fNumber, (v) => {
if (selected) selected.chapter.number = v;
});
bindField(fLabel, (v) => {
if (!selected) return;
selected.chapter.label = v;
if (selected.idAuto) {
selected.chapter.id = slug(v);
fId.value = selected.chapter.id;
}
});
bindField(fShort, (v) => {
if (selected) selected.chapter.shortLabel = v;
});
bindField(fDesc, (v) => {
if (selected) selected.chapter.description = v;
});
// ---- Capture and the list -------------------------------------------------
function capture(): CapturedPose | null {
if (options.flying?.()) {
say("Still flying — wait for the camera to land.");
return null;
}
const focus = readFocus();
const residual = measure(focus);
const number = pad(existing.length + entries.length + 1);
const label = `Untitled ${number}`;
const chapter: Chapter = {
id: slug(label),
number,
label,
shortLabel: label,
focus,
description: "",
};
const row = el("div", "item");
const name = el("button", "name") as HTMLButtonElement;
name.type = "button";
const note = el("div", "res mono");
const fly = el("button", "ico", "fly") as HTMLButtonElement;
const drop = el("button", "ico", "×") as HTMLButtonElement;
fly.type = "button";
drop.type = "button";
fly.title = "Fly to the emitted pose — the rounded numbers, not the live camera";
drop.title = "Forget this pose";
const head = el("div", "item-head");
head.append(name, fly, drop);
row.append(head, note);
const entry: Entry = { chapter, residual, idAuto: true, row, name, note };
name.addEventListener("click", () => select(entry));
fly.addEventListener("click", () => {
// Through the kit's own flight, and to the *emitted* pose rather than the
// captured one, because the emitted pose is what the paste will produce
// and the whole point is to see it before trusting it.
options.flyTo(poseOf(entry.chapter.focus));
});
drop.addEventListener("click", () => remove(entry));
entries.push(entry);
list.append(row);
refreshRow(entry);
select(entry);
warnDuplicate();
return { chapter, residual };
}
function remove(entry: Entry) {
const at = entries.indexOf(entry);
if (at < 0) return;
entries.splice(at, 1);
entry.row.remove();
if (selected === entry) select(entries[Math.min(at, entries.length - 1)] ?? null);
warnDuplicate();
}
function select(entry: Entry | null) {
if (selected) selected.row.classList.remove("sel");
selected = entry;
if (entry) entry.row.classList.add("sel");
form.classList.toggle("off", entry === null);
copyOne.disabled = entry === null;
copyAll.disabled = entries.length === 0;
fId.value = entry?.chapter.id ?? "";
fNumber.value = entry?.chapter.number ?? "";
fLabel.value = entry?.chapter.label ?? "";
fShort.value = entry?.chapter.shortLabel ?? "";
fDesc.value = entry?.chapter.description ?? "";
}
function refreshRow(entry: Entry) {
const r = entry.residual;
entry.name.textContent = `${entry.chapter.number} · ${entry.chapter.label || "—"}`;
entry.note.textContent =
`pos ${r.position.toFixed(4)}u · aim ${r.aim.toFixed(4)}° · ` +
`${(r.frame * 100).toFixed(3)}% of frame`;
entry.note.classList.toggle("bad", r.aim > AIM_WARN);
}
/**
* Two chapters with the same id is not a lint, it is a missing chapter:
* `scene.ts` keys its flights off an object built from the list, so the
* second one wins and the first is unreachable from the legend.
*/
function warnDuplicate() {
const seen = new Set(existing.map((c) => c.id));
const clashes: string[] = [];
for (const e of entries) {
if (seen.has(e.chapter.id)) clashes.push(e.chapter.id);
seen.add(e.chapter.id);
}
if (clashes.length > 0) say(`Duplicate id: ${clashes.join(", ")}`, true);
else if (msg.classList.contains("bad")) say("");
}
function say(text: string, bad = false) {
msg.textContent = text;
msg.classList.toggle("bad", bad && text !== "");
}
async function copy(text: string) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
say("Copied.");
return;
} catch {
// Permission refused, or a context the API decided was not secure
// enough after all. Fall through to the textarea.
}
}
out.hidden = false;
out.value = text;
out.focus({ preventScroll: true });
out.select();
say("Clipboard unavailable — press Ctrl/Cmd-C.");
}
function code(): string {
return emitChapters(entries.map((e) => e.chapter));
}
// ---- The live readout -----------------------------------------------------
let lastDraw = 0;
// Compared exactly rather than with an epsilon, for the reason `minimap.ts`
// spells out: OrbitControls' damping asymptotes, and an epsilon freezes the
// readout a few frames before the camera has actually stopped.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
function moved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ
);
}
function paintLive() {
const focus = readFocus();
const r = measure(focus);
liveLatLng.textContent = `lat ${num(focus.lat)} lng ${num(focus.lng)}`;
liveFocus.textContent =
`dist ${num(focus.distance)} height ${num(focus.height)} rot ${num(focus.rotation)}`;
// The 3D standoff against the controls' own ceiling, because a pose outside
// it is one `controls.update()` away from being quietly reeled in — the
// trap `sf.ts` has a paragraph about above its regional chapters.
const radius = Math.hypot(focus.distance, focus.height);
const ceiling = controls.maxDistance;
const groundM = world.unitsToMetres(controls.target.y - r.lift);
liveMeta.textContent =
`ground ${groundM.toFixed(0)} m · standoff ${radius.toFixed(1)} of ${ceiling.toFixed(0)}`;
const tight = ceiling > 0 && radius > ceiling * 0.99;
if (Math.abs(r.lift) > 1e-4 && r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent =
`Target is ${r.lift.toFixed(2)}u off the ground — a chapter cannot carry that, ` +
`so the emitted pose aims ${r.aim.toFixed(2)}° elsewhere. Re-fly a chapter to reset it.`;
} else if (tight) {
liveWarn.hidden = false;
liveWarn.textContent = "At the orbit ceiling — the pose may be reeled in on arrival.";
} else if (r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent = `Round trip is ${r.aim.toFixed(3)}° out.`;
} else {
liveWarn.hidden = true;
}
}
function tick() {
if (destroyed || !visible) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
if (!moved()) return;
lastDraw = now;
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
paintLive();
}
select(null);
paintLive();
return {
capture,
poses: () => entries.map((e) => ({ chapter: e.chapter, residual: e.residual })),
code,
tick,
setVisible(next) {
visible = next;
host.hidden = !next;
// The readout is stale by however long the panel was shut, and `moved()`
// will say nothing changed if the camera happens to be back where it was.
if (next) paintLive();
},
destroy() {
if (destroyed) return;
destroyed = true;
root.removeEventListener("keydown", containKeys);
root.removeEventListener("keyup", containKeys);
// Everything else this file listens to is on a node inside `host`, so
// removing it takes the listeners with it. The entries hold DOM that is
// inside `host` too; dropping the array is what stops them being reachable.
host.remove();
entries.length = 0;
selected = null;
},
};
}
// ---- Emission ---------------------------------------------------------------
/**
* One chapter, in the shape `cities/sf.ts` already has: two-space indent, key
* order `id, number, label, shortLabel, focus, description`, and a trailing
* comma, because what you are pasting is an element of the `CHAPTERS` array
* rather than a standalone declaration.
*/
export function emitChapter(chapter: Chapter, indent = " "): string {
const inner = `${indent} `;
return [
`${indent}{`,
`${inner}id: ${quote(chapter.id)},`,
`${inner}number: ${quote(chapter.number)},`,
`${inner}label: ${quote(chapter.label)},`,
`${inner}shortLabel: ${quote(chapter.shortLabel)},`,
emitFocus(chapter.focus, inner),
emitDescription(chapter.description, inner),
`${indent}},`,
].join("\n");
}
/** The session list as the declaration a city pack ends with. */
export function emitChapters(chapters: readonly Chapter[]): string {
const body = chapters.map((c) => emitChapter(c)).join("\n");
return `export const CHAPTERS: City["chapters"] = [\n${body}\n];\n`;
}
/**
* The focus, on one line where it fits and one key per line where it does not.
*
* Every focus in both packs is on one line today, but they were typed at four
* decimals and integer distances; a captured pose at five and two runs to about
* 97 columns and a long one goes over. This is the only formatting rule in the
* file and it is the one the rest of the tree follows by eye — there is no
* formatter in `devDependencies` to defer to.
*/
function emitFocus(focus: Chapter["focus"], indent: string): string {
const pairs = [
`lat: ${num(focus.lat)}`,
`lng: ${num(focus.lng)}`,
`distance: ${num(focus.distance)}`,
`height: ${num(focus.height)}`,
`rotation: ${num(focus.rotation)}`,
];
const flat = `${indent}focus: { ${pairs.join(", ")} },`;
if (flat.length <= COLUMNS) return flat;
return [`${indent}focus: {`, ...pairs.map((p) => `${indent} ${p},`), `${indent}},`].join("\n");
}
function emitDescription(description: string, indent: string): string {
// Collapsed to one line rather than escaped as `\n`. The field is a sentence
// in a legend; a textarea that has been typed into with the Enter key still
// means one paragraph, and a literal newline inside the quotes would not
// compile.
const text = quote(description.replace(/\s+/g, " ").trim());
const flat = `${indent}description: ${text},`;
if (flat.length <= COLUMNS) return flat;
return `${indent}description:\n${indent} ${text},`;
}
/**
* Already-rounded numbers, printed short. `String` drops the trailing zeros
* `toFixed` would leave, so a pose that lands on 0.4 emits `0.4` and matches
* what is in the packs; nothing here reaches the magnitude where JavaScript
* switches to exponent notation.
*/
function num(value: number): string {
return String(value === 0 ? 0 : value);
}
function quote(text: string): string {
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function round(value: number, dp: number): number {
return Number(value.toFixed(dp));
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function slug(label: string): string {
const s = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s === "" ? "chapter" : s;
}
// ---- DOM helpers ------------------------------------------------------------
function el(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function field(parent: HTMLElement, name: string, placeholder: string): HTMLInputElement {
const wrap = el("label", "f");
wrap.append(el("span", "k", name));
const input = document.createElement("input");
input.type = "text";
input.placeholder = placeholder;
input.spellcheck = false;
wrap.append(input);
parent.append(wrap);
return input;
}
function area(parent: HTMLElement, name: string, placeholder: string): HTMLTextAreaElement {
const wrap = el("label", "f col");
wrap.append(el("span", "k", name));
const input = document.createElement("textarea");
input.rows = 3;
input.placeholder = placeholder;
wrap.append(input);
parent.append(wrap);
return input;
}
/**
* The panel's stylesheet.
*
* Written against the application's custom properties with the literal as the
* fallback, so the tool looks like it belongs when it is mounted inside Tera
* and still looks deliberate when it is mounted in a bare page. `:host` sets no
* `all: initial` on purpose — that would reset the custom properties along with
* everything else and the fallbacks would be all anyone ever saw.
*/
const CSS = `
:host { display: block; }
:host([hidden]) { display: none; }
* { box-sizing: border-box; }
.panel {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
line-height: 1.5;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
padding: var(--s3, 12px);
display: flex;
flex-direction: column;
gap: var(--s2, 8px);
}
.hd {
text-transform: uppercase;
letter-spacing: 0.09em;
font-size: 10px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.live {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2, 8px);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border-radius: var(--r-sm, 5px);
}
.row { white-space: pre; overflow-x: auto; }
.sub { color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.warn {
margin-top: var(--s1, 4px);
color: var(--amber-ink, #ffd68a);
white-space: normal;
}
.btn {
font: inherit;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: var(--s2, 8px);
cursor: pointer;
transition: background var(--t, 150ms ease);
}
.btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.primary { color: #14202b; background: var(--amber, #f2b134); border-color: transparent; }
.btn.primary:hover { background: var(--amber-lit, #ffc555); }
.form { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.form.off { opacity: 0.35; pointer-events: none; }
.f { display: flex; align-items: center; gap: var(--s2, 8px); }
.f.col { align-items: flex-start; }
.k {
flex: 0 0 74px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
padding-top: 3px;
}
input, textarea {
font: inherit;
flex: 1 1 auto;
min-width: 0;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 3px 6px;
resize: vertical;
}
input:focus, textarea:focus { outline: 1px solid var(--amber, #f2b134); }
.list { display: flex; flex-direction: column; gap: 2px; max-height: 34vh; overflow-y: auto; }
.item {
padding: var(--s1, 4px) var(--s2, 8px);
border-radius: var(--r-sm, 5px);
border: 1px solid transparent;
}
.item.sel {
border-color: var(--amber, #f2b134);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
}
.item-head { display: flex; align-items: center; gap: var(--s1, 4px); }
.name {
font: inherit;
flex: 1 1 auto;
min-width: 0;
text-align: left;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ico {
font: inherit;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
background: none;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 0 5px;
cursor: pointer;
}
.ico:hover { color: var(--amber-ink, #ffd68a); }
.res { color: var(--ink-4, rgba(255, 255, 255, 0.26)); font-size: 10px; }
.bad { color: var(--amber-ink, #ffd68a); }
.actions { display: flex; gap: var(--s2, 8px); }
.actions .btn { flex: 1 1 0; }
.msg { min-height: 1.5em; color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.out {
width: 100%;
height: 18vh;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.mono { font-variant-numeric: tabular-nums; }
`;