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:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* The places this deployment will answer for, and the refusal of everywhere else.
|
||||
*
|
||||
* `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred
|
||||
* kilometres apart with genuinely different skies — the marine-layer comment in
|
||||
* `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own
|
||||
* weather, not San Francisco's fog. So weather and flights have to answer for a
|
||||
* *requested* place rather than for the box's one origin.
|
||||
*
|
||||
* ### Why this is an allowlist and not a lookup
|
||||
*
|
||||
* The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an
|
||||
* unauthenticated endpoint into a free geocoding proxy for the whole planet.
|
||||
* Two things go wrong with that, and neither is hypothetical. It is an
|
||||
* amplification vector: one cheap request here becomes one expensive request to
|
||||
* somebody else's public-good API, from an address they will blame. And it is
|
||||
* how a deployment's User-Agent gets blocked, because NWS's fair-use policy is
|
||||
* written against exactly this pattern and the contact string in
|
||||
* `TERA_WEATHER_CONTACT` is the operator's own name on the request.
|
||||
*
|
||||
* The rule here is therefore: **a caller's coordinate is never forwarded
|
||||
* upstream. It only selects among the points the operator configured.** A
|
||||
* request for Berkeley resolves to the San Francisco region and fetches San
|
||||
* Francisco's centre; a request for Fresno is refused with a 400 naming what is
|
||||
* served. The upstream key space is the region list, so it is bounded by the
|
||||
* environment file and cannot be grown by anybody sending requests — which is
|
||||
* the property that makes per-region caching, the NWS station cache and the
|
||||
* adsb.lol poll budget in `flights/adsb.ts` all bounded too.
|
||||
*
|
||||
* A coarse grid was the other candidate and was rejected: snapping to 0.5° still
|
||||
* leaves a caller able to name a hundred thousand distinct cells, which bounds
|
||||
* nothing that matters. Refusing is the honest answer, and a self-hoster who
|
||||
* wants their own city writes one line of `TERA_REGIONS`.
|
||||
*/
|
||||
|
||||
/** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */
|
||||
const KM_PER_DEGREE = 111.195;
|
||||
|
||||
/**
|
||||
* How far from a region's centre a request may land and still resolve there,
|
||||
* when the operator did not say.
|
||||
*
|
||||
* 120 km covers both shipped boards with room to spare — the far corner of the
|
||||
* Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving
|
||||
* the two regions comfortably disjoint, since their centres are about 440 km
|
||||
* apart. It is deliberately not tight: the point of the radius is to refuse
|
||||
* somewhere this deployment has nothing to say about, not to police the edge of
|
||||
* the rendered board.
|
||||
*/
|
||||
export const DEFAULT_RADIUS_KM = 120;
|
||||
|
||||
export interface Region {
|
||||
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Kilometres. See `DEFAULT_RADIUS_KM`. */
|
||||
radiusKm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* At least one region, always, with the first one being the default.
|
||||
*
|
||||
* A tuple rather than an array because "the default region" is read on every
|
||||
* request that omits a query, and a `Region | undefined` there would be a
|
||||
* falsehood the type system made everybody handle.
|
||||
*/
|
||||
export type RegionSet = [Region, ...Region[]];
|
||||
|
||||
/**
|
||||
* The two cities this repo ships, with the centres their packs declare —
|
||||
* `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`.
|
||||
*
|
||||
* They are restated rather than imported for the same reason `wire.ts` restates
|
||||
* `SimRoute`: a city pack is three thousand lines of coastline for a renderer
|
||||
* that owns three.js, and the API has one runtime dependency and intends to keep
|
||||
* it. Two numbers each, and the day a pack moves its centre the weather resolves
|
||||
* to a point a few kilometres off, which is a rounding error against a radius of
|
||||
* 120 km.
|
||||
*/
|
||||
const SHIPPED: Region[] = [
|
||||
{ id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM },
|
||||
{ id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM },
|
||||
];
|
||||
|
||||
/**
|
||||
* `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar.
|
||||
*
|
||||
* The decimal places are capped in the pattern rather than checked afterwards
|
||||
* because it is the same cap the query parser applies, and the two agreeing is
|
||||
* the point: a coordinate nobody could ask for is a coordinate nobody should be
|
||||
* able to configure either.
|
||||
*/
|
||||
const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/;
|
||||
|
||||
export interface LoadRegionsOptions {
|
||||
/** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */
|
||||
spec: string;
|
||||
/** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */
|
||||
origin: { lat: number; lng: number };
|
||||
/** Whether the operator actually wrote an origin, as opposed to inheriting SF. */
|
||||
originConfigured: boolean;
|
||||
degraded: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The region set for this box.
|
||||
*
|
||||
* Three cases, in order, and the ordering is what keeps every existing
|
||||
* deployment answering exactly as it did:
|
||||
*
|
||||
* 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order.
|
||||
* 2. Otherwise the two shipped cities, because that is what the bundled map
|
||||
* draws and serving only one of them is wrong for half the board.
|
||||
* 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere
|
||||
* else gets that point as a region of its own, first in the list and
|
||||
* therefore the default. A bare `GET /api/v1/weather` on their box answers
|
||||
* for their origin, exactly as it did before this file existed. If their
|
||||
* origin already falls inside a shipped region, that region moves to the
|
||||
* front instead of being duplicated.
|
||||
*
|
||||
* Malformed entries are dropped with a line in `degraded` rather than being
|
||||
* fatal, and a spec in which *nothing* parses falls all the way back to case 2.
|
||||
* CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot.
|
||||
*/
|
||||
export function loadRegions(opts: LoadRegionsOptions): RegionSet {
|
||||
const configured = parseSpec(opts.spec, opts.degraded);
|
||||
if (configured.length > 0) return asSet(configured, configured[0] as Region);
|
||||
|
||||
const shipped = SHIPPED.map((region) => ({ ...region }));
|
||||
const containing = shipped.findIndex((region) => contains(region, opts.origin));
|
||||
|
||||
if (containing > 0) {
|
||||
const moved = shipped[containing] as Region;
|
||||
shipped.splice(containing, 1);
|
||||
shipped.unshift(moved);
|
||||
} else if (containing === -1 && opts.originConfigured) {
|
||||
shipped.unshift({
|
||||
id: "origin",
|
||||
lat: opts.origin.lat,
|
||||
lng: opts.origin.lng,
|
||||
radiusKm: DEFAULT_RADIUS_KM,
|
||||
});
|
||||
}
|
||||
|
||||
return asSet(shipped, shipped[0] as Region);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Region[]` to `RegionSet`, with the caller supplying the head it has already
|
||||
* proved is there. The alternative is a cast on the whole array, which would
|
||||
* also silence the empty case this type exists to rule out.
|
||||
*/
|
||||
function asSet(regions: Region[], head: Region): RegionSet {
|
||||
return [head, ...regions.slice(1)];
|
||||
}
|
||||
|
||||
function parseSpec(spec: string, degraded: string[]): Region[] {
|
||||
const trimmed = spec.trim();
|
||||
if (trimmed === "") return [];
|
||||
|
||||
const regions: Region[] = [];
|
||||
for (const raw of trimmed.split(/[;\n]/)) {
|
||||
const entry = raw.trim();
|
||||
if (entry === "") continue;
|
||||
|
||||
const match = ENTRY.exec(entry);
|
||||
if (match === null) {
|
||||
degraded.push(
|
||||
`TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` +
|
||||
`\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = match[1] as string;
|
||||
const lat = Number(match[2]);
|
||||
const lng = Number(match[3]);
|
||||
const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]);
|
||||
|
||||
if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) {
|
||||
degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`);
|
||||
continue;
|
||||
}
|
||||
if (regions.some((region) => region.id === id)) {
|
||||
degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
regions.push({ id, lat, lng, radiusKm });
|
||||
}
|
||||
|
||||
if (regions.length === 0 && trimmed !== "") {
|
||||
degraded.push(
|
||||
"TERA_REGIONS was set but nothing in it parsed; serving the two cities the " +
|
||||
"map ships with instead.",
|
||||
);
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
// ---- Answering a request --------------------------------------------------
|
||||
|
||||
/**
|
||||
* What a route hands in. `unknown` throughout because this is untrusted query
|
||||
* input: Fastify hands back a string for `?lat=1`, an **array** for
|
||||
* `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed
|
||||
* `string` would be a lie the first time somebody repeated a parameter.
|
||||
*/
|
||||
export interface RegionQuery {
|
||||
city?: unknown;
|
||||
lat?: unknown;
|
||||
lng?: unknown;
|
||||
}
|
||||
|
||||
export type RegionResolution =
|
||||
| { ok: true; region: Region }
|
||||
/** Already a sentence. The route sends it as `ErrorBody.message`. */
|
||||
| { ok: false; message: string };
|
||||
|
||||
/** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */
|
||||
const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/;
|
||||
|
||||
const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
|
||||
/**
|
||||
* Turn a query into one of the configured regions, or into a refusal.
|
||||
*
|
||||
* Every branch that is not a resolved region is a **400**, not a degraded body.
|
||||
* That is the one place this file departs from the "degrade, never fail"
|
||||
* convention, and the distinction is who made the mistake: an upstream that is
|
||||
* down is not the caller's fault and must not become their problem, whereas
|
||||
* `?lat=banana` is a client bug that a 200 full of clear sky would hide until
|
||||
* somebody wondered why the fog never rolls in.
|
||||
*/
|
||||
export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution {
|
||||
const city = query.city;
|
||||
const hasCity = city !== undefined && city !== "";
|
||||
const hasLat = query.lat !== undefined && query.lat !== "";
|
||||
const hasLng = query.lng !== undefined && query.lng !== "";
|
||||
|
||||
if (hasCity && (hasLat || hasLng)) {
|
||||
return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." };
|
||||
}
|
||||
|
||||
if (hasCity) {
|
||||
if (typeof city !== "string" || !CITY_ID.test(city)) {
|
||||
return { ok: false, message: `city must be one of: ${served(regions)}.` };
|
||||
}
|
||||
const region = regions.find((candidate) => candidate.id === city);
|
||||
if (region === undefined) {
|
||||
return { ok: false, message: `This deployment serves: ${served(regions)}.` };
|
||||
}
|
||||
return { ok: true, region };
|
||||
}
|
||||
|
||||
if (hasLat !== hasLng) {
|
||||
return { ok: false, message: "lat and lng have to be given together." };
|
||||
}
|
||||
|
||||
if (!hasLat) return { ok: true, region: regions[0] };
|
||||
|
||||
const lat = degrees(query.lat, 90);
|
||||
const lng = degrees(query.lng, 180);
|
||||
if (lat === null || lng === null) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"lat and lng must be plain decimal degrees within ±90 and ±180, " +
|
||||
"with at most six decimal places.",
|
||||
};
|
||||
}
|
||||
|
||||
const region = nearest(regions, lat, lng);
|
||||
if (region === null) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`Nothing this deployment serves is near ${lat},${lng}. It serves: ` +
|
||||
`${served(regions)}. Ask for one of those by id, or add yours to ` +
|
||||
`TERA_REGIONS on the server.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, region };
|
||||
}
|
||||
|
||||
function served(regions: RegionSet): string {
|
||||
return regions.map((region) => region.id).join(", ");
|
||||
}
|
||||
|
||||
function degrees(raw: unknown, limit: number): number | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
// The pattern is doing the work `Number()` would do badly: it rejects `NaN`,
|
||||
// `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which
|
||||
// `Number()` either accepts or turns into a value that then has to be
|
||||
// re-checked. One regex, one meaning.
|
||||
if (!DEGREES.test(trimmed)) return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
/** The closest region that claims the point, or `null` if none of them does. */
|
||||
function nearest(regions: RegionSet, lat: number, lng: number): Region | null {
|
||||
let best: Region | null = null;
|
||||
let bestKm = Infinity;
|
||||
for (const region of regions) {
|
||||
const km = distanceKm(region, lat, lng);
|
||||
if (km <= region.radiusKm && km < bestKm) {
|
||||
best = region;
|
||||
bestKm = km;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equirectangular rather than haversine, because at these distances the error is
|
||||
* under half a percent and the comparison it feeds is against a radius chosen to
|
||||
* the nearest ten kilometres. Trigonometry accurate to the metre would be
|
||||
* decorating a threshold that is deliberately fuzzy.
|
||||
*/
|
||||
function distanceKm(region: Region, lat: number, lng: number): number {
|
||||
const dLat = lat - region.lat;
|
||||
const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180;
|
||||
const dLng = (lng - region.lng) * Math.cos(meanLat);
|
||||
return Math.hypot(dLat, dLng) * KM_PER_DEGREE;
|
||||
}
|
||||
|
||||
function contains(region: Region, point: { lat: number; lng: number }): boolean {
|
||||
return distanceKm(region, point.lat, point.lng) <= region.radiusKm;
|
||||
}
|
||||
Reference in New Issue
Block a user