SoCal, the whole bay, a moon, and gates that actually run
gates / clean-clone (push) Successful in 15s
gates / zero-config-boot (push) Successful in 9s
gates / no-binary-art (push) Successful in 4s

Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 03:13:32 -07:00
parent 8bcb391455
commit 44c5a79424
25 changed files with 9246 additions and 220 deletions
+105
View File
@@ -0,0 +1,105 @@
# Adapters
Everything in `src/engine/` renders data and takes no position on what it means.
A `Marker` is a point with a `colorKey`; the engine looks that key up in a
palette the caller supplies and will never learn that `rejected` is red. A
`FlightSource` is an interface with a `poll()` on it. A `WeatherObservation` is
six numbers.
This directory is where those become somebody's actual data. It is the only part
of the browser build that knows an API exists, and keeping it here is what lets
one renderer serve a private career map, a public sector map and whatever anyone
else builds without any of them being a fork. See ARCHITECTURE.md §3.3.
| file | what it is |
| --- | --- |
| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it |
| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` |
## The fallback is the product, not the safety net
`http.ts` never throws and never leaves the map empty. No server, a 404, a
timeout, a static host answering `/api/v1/markers` with its own `index.html`
all of it lands on the sample data, and the city keeps rendering.
That is deliberate and it is the acceptance test the whole repo is held to: a
stranger clones this, runs one command, and gets a city, with no account, no key
and no network (CONTRACT.md §0). `npm run build` deployed to any static host is a
working Tera. Pointing it at a server is an upgrade.
Every response carries `live: boolean` so the difference is visible to the app
above. An interface that shows invented companies exactly the way it shows real
ones is the one failure mode this arrangement can have, and the flag is there so
it does not have to happen.
```ts
const tera = createTeraClient(); // same-origin /api/v1
const markers = await tera.markers();
const scene = createScene(canvas, {
city: SAN_FRANCISCO,
markerPalette: markers.palette,
flights: tera.flights(),
});
scene.setMarkers(markers.value);
if (!markers.live) showSampleDataNotice();
```
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.
## No real company data ships in this repo
Two separate constraints want the same thing here, which is the reason this
arrangement is worth the indirection rather than just committing a JSON file.
**Privacy.** Pipeline status — who is talking to whom, and who said no — is
private. A public repo is the wrong place for it.
**Licence, which is the sharper one.** Real positions are geocoded, and a
geocoder built on OpenStreetMap returns ODbL data. ODbL is share-alike, and
serving a snapshot of those coordinates from a public endpoint is *Publicly
Using a Derivative Database* — §4.3 attribution and §4.4 share-alike attach to
the served data whether or not the rows live in the repo. Keeping the table
off-disk hides that obligation; it does not discharge it. See ARCHITECTURE.md
§3.2 and the correction in CONTRACT.md §8.
So the rule is about the geocoder, not about storage:
- Geography in `src/cities/` is **traced by hand**. Original expression, ours,
Apache-2.0. Never OSM, never Nominatim.
- Real markers arrive over the API at runtime, each carrying a
`CoordinateProvenance`, and the server refuses any row whose provenance is not
on a non-ODbL allowlist — `us-census`, `hand-placed`, `synthetic`.
- `http.ts` passes `refused` straight through rather than swallowing it, because
a gate that drops rows silently is indistinguishable from an empty database.
It does **not** re-run the gate in the browser: the allowlist is the server's,
and a self-hoster who added their own provenance value should not watch their
own rows vanish client-side.
- Everything in `sample.ts` is invented. The companies do not exist, the
positions were typed by hand from a general sense of where San Francisco's
neighbourhoods are, and the names are absurd on purpose so that none of them
can be mistaken for a real business.
## Writing your own
`http.ts` is one adapter, not the adapter. Anything that can produce
`Marker[]`, a `WeatherObservation` and a `FlightSource` is one — a local JSON
file, a Postgres query through your own backend, an SDR on the windowsill. The
engine imports nothing from this directory, so an adapter can be deleted, forked
or replaced without touching a line of the renderer.
Two things to keep if you write one:
**Traffic must not block the render loop.** `poll()` may be synchronous, and
`HttpFlights` is: it answers from whatever is in hand and refreshes on the
body's own TTL in the background. A `poll()` that awaits a slow fetch puts a
frame behind a round trip.
**FlightRadar24 is not an option.** Their terms forbid scraping and forbid
redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
publishing instructions for breaking a ToS and shipping data it has no right to
relicense. `src/engine/flights.ts` ships a simulator and points at the open
community ADS-B feeds instead; anything commercial belongs in an adapter in a
private deployment. See ARCHITECTURE.md §4.
+333
View File
@@ -0,0 +1,333 @@
/**
* The real adapter: markers, weather and traffic from the Tera API, with the
* bundled sample data underneath it.
*
* This is the one place in the browser build that knows the API exists. The
* engine takes `Marker[]`, a `WeatherObservation` and a `FlightSource` and has
* no idea where any of them came from — that boundary is what lets one renderer
* serve a private career map and a public sector map without either being a
* fork (ARCHITECTURE.md §3.3), and it is why this file is an adapter rather than
* a client scattered through the scene.
*
* **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
* working Tera; pointing it at a server is an upgrade, not a requirement.
*
* 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 type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
import { seededRandom } from "../engine/world.ts";
import type {
FlightsBody,
FlightsPlanBody,
HealthBody,
MarkersBody,
OfficeDoc,
WeatherBody,
} from "../server/wire.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts";
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const DEFAULT_BASE = "/api/v1";
/** How long any one request may take before the fallback is used instead. */
const DEFAULT_TIMEOUT_MS = 4000;
/** How long to wait before trying the flights endpoint again after it fails. */
const RETRY_SECONDS = 30;
export interface TeraApiOptions {
/**
* Base URL, with no trailing slash. Absolute is allowed and is what a
* self-hoster running the browser build and the API on different origins
* wants; the default assumes they are the same origin.
*/
base?: string;
/** Injected for tests. Absent means `globalThis.fetch`. */
fetch?: typeof fetch;
timeoutMs?: number;
/**
* The palette live markers are coloured by.
*
* The engine looks `colorKey` up and the wire does not carry colours, so
* somebody has to supply this and it cannot be the server: what a key *means*
* is the consuming app's business. When the API is absent this is ignored and
* `SAMPLE_PALETTE` is used instead, because sample keys are not the caller's
* keys.
*/
palette?: MarkerPalette;
}
/**
* A response, plus whether it is real.
*
* `live` is the field that stops a fallback from being a lie. A demo showing
* invented companies and a deployment showing real ones must not look identical
* to the code above them — the caller is expected to say so in the interface,
* and cannot if the adapter quietly papers over the difference.
*/
export interface Feed<T> {
value: T;
live: boolean;
}
export interface MarkerFeed extends Feed<Marker[]> {
/** The palette these markers are meant to be read with. */
palette: MarkerPalette;
/** ISO-8601 snapshot time, or `null` for the sample set, which has no date. */
generatedAt: string | null;
/**
* Rows the server's public-shape gate refused, by reason and count.
*
* Passed through rather than swallowed. A gate that drops rows silently is
* indistinguishable from an empty database, which is exactly the confusion
* `MarkersBody.refused` exists to prevent — and it is the visible end of the
* provenance rule in CONTRACT.md §8.
*/
refused: { reason: string; count: number }[];
attribution: string[];
}
export interface WeatherFeed extends Feed<WeatherObservation> {
attribution: string[];
}
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`.
*/
flights(): FlightSource;
/**
* 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
* used to enumerate what exists (CONTRACT.md §6).
*
* There is deliberately no fallback here. A missing marker can be stood in for
* by a fictional one; a missing floorplan cannot be invented, and an app with
* a bundled office of its own already has the better answer.
*/
office(id: string): Promise<OfficeDoc | null>;
}
export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, "");
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
/**
* 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.
*/
async function get<T>(path: string): Promise<T | null> {
if (!doFetch) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await doFetch(`${base}${path}`, {
signal: controller.signal,
headers: { accept: "application/json" },
});
if (!res.ok) return null;
// Checked rather than trusted: a static host answers an unknown path with
// the SPA shell and a 200, and `res.json()` on HTML throws where a content
// type check just returns.
const type = res.headers.get("content-type") ?? "";
if (!type.includes("json")) return null;
return (await res.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
let flightSource: FlightSource | null = null;
return {
health: () => get<HealthBody>("/health"),
async markers(): Promise<MarkerFeed> {
const body = await get<MarkersBody>("/markers");
if (!body || !Array.isArray(body.markers)) return sampleMarkerFeed();
return {
// A `WireMarker` *is* a `Marker` with a provenance field on it, so this
// is a widening and not a translation — which is the property
// `wire.ts` chose the shape for. The provenance itself is the server's
// to enforce and is deliberately not re-checked here: a browser
// silently dropping rows a self-hoster explicitly allowlisted would look
// exactly like an empty database.
value: body.markers,
live: true,
palette: options.palette ?? {},
generatedAt: body.generatedAt ?? null,
refused: body.refused ?? [],
attribution: body.attribution ?? [],
};
},
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 ?? [] };
},
flights(): FlightSource {
flightSource ??= new HttpFlights(get, SAMPLE_ROUTES);
return flightSource;
},
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
};
}
/**
* The clear day a zero-config box serves, restated in the browser.
*
* 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.
*/
const CLEAR_DAY: WeatherObservation = {
cloudCover: 0.1,
precipitation: 0,
visibilityKm: null,
windKph: null,
windDirDeg: null,
condition: "clear",
};
function sampleMarkerFeed(): MarkerFeed {
return {
value: SAMPLE_MARKERS,
live: false,
palette: SAMPLE_PALETTE,
generatedAt: null,
refused: [],
attribution: [],
};
}
// ---- Traffic --------------------------------------------------------------
/**
* Traffic over HTTP, in whichever of the two shapes the server chose.
*
* `poll()` is synchronous and never awaits the network, which is the whole
* design. `scene.ts` calls it from the render loop, and a source that returned a
* promise resolving on a slow fetch would put a frame's aircraft update behind a
* round trip; instead the network runs in the background on the body's own TTL
* and `poll` answers from whatever is currently in hand.
*
* The two modes are not symmetrical, and `wire.ts` explains why. A *plan* — the
* simulator's routes, a fixed epoch and a seed — is evaluated locally at one
* request per TTL, and because the epoch is fixed rather than the server's start
* time, two people on different machines see the same aircraft in the same
* places. *Live* traffic has no closed form, so it arrives as positions and is
* 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`.
*/
class HttpFlights implements FlightSource {
/**
* 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;
* the network is on `nextFetchAt` and is a great deal slower.
*/
readonly interval = 1;
private readonly fallback: SimulatedFlights;
private plan: FlightsPlanBody | null = null;
private planPhase: number[] = [];
private live: Aircraft[] | null = null;
private nextFetchAt = 0;
private fetching = false;
constructor(
private readonly get: <T>(path: string) => Promise<T | null>,
fallbackRoutes: SimRoute[],
) {
this.fallback = new SimulatedFlights(fallbackRoutes);
}
poll(): Aircraft[] {
this.refreshIfStale();
if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now());
if (this.live) return this.live;
return this.fallback.poll();
}
private refreshIfStale(): void {
const now = Date.now();
if (this.fetching || now < this.nextFetchAt) return;
this.fetching = true;
void this.get<FlightsBody>("/flights")
.then((body) => {
if (!body) {
// Hold whatever was already in hand rather than reverting to the
// simulator: a deployment that has been showing real traffic for an
// hour and drops one request should keep showing it, slightly stale,
// not silently swap in fiction.
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;
})
.finally(() => {
this.fetching = false;
});
}
}
/**
* The per-route phase offsets, from the seed the server sent.
*
* Same generator and same order as `SimulatedFlights`, which is what makes the
* server's promise true: every viewer draws the seed once, in route order, and
* arrives at the same sky.
*/
function phasesFor(plan: FlightsPlanBody): number[] {
const rand = seededRandom(plan.seed);
return plan.routes.map(() => rand());
}
function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Aircraft[] {
const seconds = (nowMs - plan.t0) / 1000;
// `WireSimRoute` is structurally `SimRoute`; the restatement in `wire.ts` is
// 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)));
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Fabricated demo data, so that a clone of this repo has something on it.
*
* **Everything in this file is invented.** The companies do not exist, have
* never existed, and are named the way they are — Wobbegong, Nonsuch, Pennyfarthing
* — specifically so that nobody can mistake one for a real business. The
* coordinates were typed by hand from a general sense of where San Francisco's
* neighbourhoods are; none of them is anybody's address, and none of them came
* out of a geocoder.
*
* That last point is the licence rule and not a stylistic preference. No real
* company data ships in this repo, for two reasons that happen to want the same
* thing. The privacy one is obvious: pipeline status — who is talking to whom,
* who said no — is private, and a public repo is the wrong place for it. The
* licence one is sharper and is the subject of ARCHITECTURE.md §3.2 and
* CONTRACT.md §8: real positions are *geocoded*, and a geocoder built on
* OpenStreetMap returns ODbL data. Serving a snapshot of those coordinates is
* Publicly Using a Derivative Database, which drags share-alike onto everything
* served alongside it, whether or not the rows live in the repo. So real markers
* arrive at runtime over the API — see `http.ts` — carrying a provenance field
* the server checks, and the repo itself ships this: fiction, which owes nobody
* anything.
*
* The demo is worth having anyway. A map with no pins on it teaches nobody what
* the thing is for, and half the interesting behaviour in `markers.ts` — the
* ghost treatment for a marker that has no real position yet — is invisible
* without data that exercises it.
*/
import type { SimRoute } from "../engine/flights.ts";
import type { Marker, MarkerPalette } from "../engine/types.ts";
/**
* A small pipeline, as colours.
*
* Five states is about the fewest that still shows why `colorKey` is opaque to
* the engine: none of these words means anything to `markers.ts`, which looks
* the key up here and draws whatever it finds. A public sector map would supply
* an entirely different set against the same renderer.
*/
export const SAMPLE_PALETTE: MarkerPalette = {
watching: 0x7f8b99,
applied: 0x4f9cf2,
talking: 0x3fbf9a,
offer: 0xf2b134,
closed: 0xd2544f,
};
/**
* Where the unplaced markers float: out over the bay, east of the Ferry
* Building, in a short arc.
*
* A marker with `located: false` has no address yet, and the honest thing to do
* with a position you do not have is to not pretend you have one. Open water is
* the clearest way to say that on a map — nothing is there, the pin visibly
* stands on nothing, and it cannot be misread as a building. `markers.ts` gives
* these a different silhouette and a lower opacity as well, so the tell does not
* rest on placement alone.
*/
const UNPLACED_LNG = -122.3665;
/**
* Twenty-two invented companies across San Francisco.
*
* Placed by neighbourhood rather than by street: Hayes Valley and SoMa are
* crowded because that is the fact the map is usually drawing attention to, the
* Bayview and the Richmond have one each, and three have no position at all.
* The clustering is the point — a marker layer that only ever gets evenly
* scattered test data hides every overlap problem it has.
*/
export const SAMPLE_MARKERS: Marker[] = [
// Hayes Valley and the bowl below Buena Vista.
{
id: "sample-thimbleway",
label: "Thimbleway Systems",
colorKey: "talking",
lat: 37.7768,
lng: -122.4243,
located: true,
blurb: "Invented. Model-serving, allegedly, in a Victorian with bad wiring.",
},
{
id: "sample-nonsuch",
label: "Nonsuch Cartography Co.",
colorKey: "applied",
lat: 37.7752,
lng: -122.4262,
located: true,
blurb: "Invented. Maps of places that are not there.",
},
{
id: "sample-marmalade",
label: "Marmalade Interchange",
colorKey: "watching",
lat: 37.7781,
lng: -122.4218,
located: true,
blurb: "Invented. Moves data between two formats nobody uses.",
},
// SoMa, where the grid turns forty-six degrees.
{
id: "sample-kettle-anvil",
label: "Kettle & Anvil Compute",
colorKey: "offer",
lat: 37.7805,
lng: -122.4051,
located: true,
blurb: "Invented. Sells the shovels, or claims to.",
},
{
id: "sample-ninth-pelican",
label: "Ninth Pelican Labs",
colorKey: "applied",
lat: 37.7784,
lng: -122.4009,
located: true,
blurb: "Invented. There were never eight others.",
},
{
id: "sample-brassbound",
label: "Brassbound Telemetry",
colorKey: "closed",
lat: 37.7822,
lng: -122.4074,
located: true,
blurb: "Invented. Went quiet after the second call.",
},
// The Financial District and Jackson Square.
{
id: "sample-grimsby-doone",
label: "Grimsby & Doone Photonics",
colorKey: "watching",
lat: 37.7941,
lng: -122.4008,
located: true,
blurb: "Invented. Two surnames and a laser.",
},
{
id: "sample-tugboat",
label: "Tugboat Actuarial",
colorKey: "applied",
lat: 37.7958,
lng: -122.4032,
located: true,
blurb: "Invented. Insurance for things that have already happened.",
},
// Mission Bay: landfill, then biotech.
{
id: "sample-fogbank",
label: "Fogbank Freight",
colorKey: "talking",
lat: 37.7709,
lng: -122.3918,
located: true,
blurb: "Invented. Logistics, in a building younger than most of the staff.",
},
{
id: "sample-bittern",
label: "Bittern & Sons Biologics",
colorKey: "watching",
lat: 37.7688,
lng: -122.3894,
located: true,
blurb: "Invented. No sons.",
},
// The Mission, flat and sunny.
{
id: "sample-unlikely-weather",
label: "Bureau of Unlikely Weather",
colorKey: "offer",
lat: 37.7602,
lng: -122.4151,
located: true,
blurb: "Invented. Forecasts nobody asked for.",
},
{
id: "sample-perpetual-bagel",
label: "Perpetual Bagel Works",
colorKey: "closed",
lat: 37.7574,
lng: -122.4192,
located: true,
blurb: "Invented. The name was the whole pitch.",
},
// Potrero Hill and Dogpatch, the old industrial edge.
{
id: "sample-wobbegong",
label: "Wobbegong Robotics",
colorKey: "talking",
lat: 37.7589,
lng: -122.4002,
located: true,
blurb: "Invented. Named after a carpet shark, for reasons never explained.",
},
{
id: "sample-sourdough-semi",
label: "Sourdough Semiconductor",
colorKey: "applied",
lat: 37.7597,
lng: -122.3881,
located: true,
blurb: "Invented. A fab in a city with no fabs.",
},
{
id: "sample-pennyfarthing",
label: "Pennyfarthing Power",
colorKey: "watching",
lat: 37.7564,
lng: -122.3973,
located: true,
blurb: "Invented. Batteries, uphill.",
},
// The north side and the hills.
{
id: "sample-lamplighter",
label: "Lamplighter Aerostatics",
colorKey: "watching",
lat: 37.8004,
lng: -122.4086,
located: true,
blurb: "Invented. Airships, which are always about to come back.",
},
{
id: "sample-halfpenny",
label: "Halfpenny Optics",
colorKey: "applied",
lat: 37.7929,
lng: -122.4147,
located: true,
blurb: "Invented. Lenses, four hundred feet above the water they look at.",
},
// One each in the parts of the city the map usually forgets.
{
id: "sample-cormorant",
label: "Cormorant Freight Systems",
colorKey: "talking",
lat: 37.7357,
lng: -122.3908,
located: true,
blurb: "Invented. The only pin south of Islais Creek, which is the point.",
},
{
id: "sample-tidewrack",
label: "Tidewrack Instruments",
colorKey: "watching",
lat: 37.7802,
lng: -122.4638,
located: true,
blurb: "Invented. Sensors, in the fog, on purpose.",
},
// Three with no position yet. See `UNPLACED_LNG`.
{
id: "sample-quibble",
label: "Quibble Quantum",
colorKey: "applied",
lat: 37.7965,
lng: UNPLACED_LNG,
located: false,
blurb: "Invented, and unplaced: no address on file, so the map does not invent one.",
},
{
id: "sample-antelope-foundry",
label: "Antelope Foundry",
colorKey: "watching",
lat: 37.7905,
lng: UNPLACED_LNG,
located: false,
blurb: "Invented, and unplaced.",
},
{
id: "sample-mudlark",
label: "Mudlark Instruments",
colorKey: "closed",
lat: 37.7845,
lng: UNPLACED_LNG,
located: false,
blurb: "Invented, and unplaced.",
},
];
/**
* Sample traffic, 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
* crossing the bay — because that is what makes the sky read as this city's sky
* rather than as random motion. The callsigns are not: no real operator uses
* 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.
*/
export const SAMPLE_ROUTES: SimRoute[] = [
{ callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
{ callsign: "NIMBUS 17", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
{ callsign: "PELICAN 2", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
{ callsign: "PELICAN 31", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
{ callsign: "CORMORANT 8", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
{ 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 },
];
+1663 -31
View File
File diff suppressed because it is too large Load Diff
+3152
View File
File diff suppressed because it is too large Load Diff
+521 -19
View File
@@ -20,11 +20,12 @@
* and touches nothing. Call it every frame or once a minute; the cost is
* the same table lookup either way.
*
* The solar half is computed locally by `solar.ts` with no network, and the
* weather half degrades to `null` — which this file reads as a clear day with
* the local climatology still running. The whole engine has to work with no
* account, no key and no network, and a sky that goes flat grey the moment the
* wifi drops would fail that in the most visible way possible.
* The solar half is computed locally by `solar.ts` with no network, the lunar
* half by `moonPosition` below, and the weather half degrades to `null` — which
* this file reads as a clear day with the local climatology still running. The
* whole engine has to work with no account, no key and no network, and a sky
* that goes flat grey the moment the wifi drops would fail that in the most
* visible way possible.
*
* Wiring one to a city, in full:
*
@@ -100,18 +101,205 @@ export interface WeatherObservation {
export interface Environment {
time: Date;
sun: SolarPosition;
/**
* The other light in the sky. Required rather than optional because a night
* without it is the black rectangle this file exists to avoid, and an
* `Environment` assembled without one would silently be that.
*/
moon: MoonPosition;
/** `null` when nobody was asked. A supported state, not an error. */
weather: WeatherObservation | null;
}
/** Build an `Environment` for a place and an instant, computing the sun locally. */
/**
* Build an `Environment` for a place and an instant, computing both bodies
* locally.
*/
export function observe(
lat: number,
lng: number,
when: Date,
weather: WeatherObservation | null = null,
): Environment {
return { time: when, sun: solarPosition(lat, lng, when), weather };
return {
time: when,
sun: solarPosition(lat, lng, when),
moon: moonPosition(lat, lng, when),
weather,
};
}
// ---- The moon -------------------------------------------------------------
/**
* Where the moon is, and how much of it is lit.
*
* This lives here rather than in `solar.ts` because it exists for exactly one
* consumer: the key light after sunset. `solar.ts` is the sun's own module and
* has a much harder accuracy contract to keep — sunrise to the minute — whereas
* nothing downstream of this can tell a tenth of a degree of moon from the
* right answer.
*/
export interface MoonPosition {
/** Degrees clockwise from true north, matching `SolarPosition.azimuth`. */
azimuth: number;
/** Degrees above the horizon, corrected for parallax. */
elevation: number;
/** Fraction of the visible disc in sunlight: 0 at new, 1 at full. */
illuminated: number;
/** 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */
phase: number;
/** Centre to centre, in kilometres. Roughly 356,500 to 406,700. */
distanceKm: number;
}
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const JD_UNIX_EPOCH = 2_440_587.5;
const J2000 = 2_451_545;
/** Equatorial radius, for the parallax correction. */
const EARTH_RADIUS_KM = 6378.14;
/**
* The moon's position and phase, computed rather than fetched.
*
* Meeus, *Astronomical Algorithms* chapter 47, truncated hard. The full ELP
* series is sixty periodic terms in longitude alone; what is here is the
* thirteen largest, which are the ones with names — the equation of centre
* (6.29°, the orbit being an ellipse), the **evection** (1.27°, the sun pulling
* the orbit's own ellipse around every 32 days and the single largest thing
* Ptolemy did not know about), the **variation** (0.66°, the moon running fast
* at the syzygies and slow at the quadratures), the annual equation and the
* parallactic inequality. Against Meeus's own worked example 47.a — 1992 April
* 12, 0h TD — this returns 133.150° against his 133.163°, a latitude of -3.223°
* against -3.229°, and 368,335 km against 368,410. Thirteen thousandths of a
* degree. The moon's disc is half a degree wide, so a renderer cannot see the
* error and neither can anyone looking at it.
*
* Two deliberate omissions. The eccentricity factor `E` that Meeus applies to
* every term in the sun's mean anomaly is dropped, because it is a correction
* of about 0.017 to terms already under a fifth of a degree. And the elevation
* is not refracted — `solar.ts` owns that curve and does not export it, and at
* moonrise the moon is contributing almost nothing anyway. Parallax *is*
* applied, because it is the big one: the moon is close enough that standing on
* the surface of the Earth rather than at its centre moves it by most of a
* degree, which is fifty times the truncation error.
*/
export function moonPosition(lat: number, lng: number, when: Date): MoonPosition {
const jd = when.getTime() / MS_PER_DAY + JD_UNIX_EPOCH;
const t = (jd - J2000) / 36_525;
const sin = (deg: number) => Math.sin(deg * DEG);
const cos = (deg: number) => Math.cos(deg * DEG);
// The Delaunay arguments, Meeus 47.1-47.5: the moon's mean longitude, its
// mean elongation from the sun, the sun's mean anomaly, the moon's own mean
// anomaly, and its argument of latitude — the angle from the ascending node,
// which is what makes the moon's path wander 5° either side of the ecliptic.
const meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t * t;
const elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t * t;
const sunAnomaly = 357.5291092 + 35_999.0502909 * t - 0.0001536 * t * t;
const anomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t * t;
const argLatitude = 93.272095 + 483_202.0175233 * t - 0.0036539 * t * t;
const d = elongation;
const m = sunAnomaly;
const mp = anomaly;
const f = argLatitude;
const longitude =
meanLongitude +
6.288774 * sin(mp) + // equation of centre
1.274027 * sin(2 * d - mp) + // evection
0.658314 * sin(2 * d) + // variation
0.213618 * sin(2 * mp) -
0.185116 * sin(m) - // annual equation
0.114332 * sin(2 * f) +
0.058793 * sin(2 * d - 2 * mp) +
0.057066 * sin(2 * d - m - mp) +
0.053322 * sin(2 * d + mp) +
0.045758 * sin(2 * d - m) -
0.040923 * sin(m - mp) -
0.03472 * sin(d) - // parallactic inequality
0.030383 * sin(m + mp);
const latitude =
5.128122 * sin(f) +
0.280602 * sin(mp + f) +
0.277693 * sin(mp - f) +
0.173237 * sin(2 * d - f) +
0.055413 * sin(2 * d - mp + f) +
0.046271 * sin(2 * d - mp - f) +
0.032573 * sin(2 * d + f) +
0.017198 * sin(2 * mp + f) +
0.009266 * sin(2 * d + mp - f) +
0.008822 * sin(2 * mp - f);
const distanceKm =
385_000.56 -
20_905.355 * cos(mp) -
3699.111 * cos(2 * d - mp) -
2955.968 * cos(2 * d) -
569.925 * cos(2 * mp);
// The sun's apparent longitude, to the same standard: needed only for the
// elongation the phase is read off, where a hundredth of a degree is three
// decimal places more than the illuminated fraction can carry.
const sunLongitude =
280.46646 +
36_000.76983 * t +
1.914602 * sin(m) +
0.019993 * sin(2 * m) +
0.000289 * sin(3 * m);
// Ecliptic to equatorial.
const obliquity = (23.4392911 - 0.0130042 * t) * DEG;
const lambda = longitude * DEG;
const beta = latitude * DEG;
const rightAscension = Math.atan2(
Math.sin(lambda) * Math.cos(obliquity) - Math.tan(beta) * Math.sin(obliquity),
Math.cos(lambda),
);
const declination = Math.asin(
clamp(
Math.sin(beta) * Math.cos(obliquity) + Math.cos(beta) * Math.sin(obliquity) * Math.sin(lambda),
-1,
1,
),
);
// Equatorial to horizontal, through the local hour angle. Greenwich sidereal
// time is Meeus 12.4: the extra 0.98564736629° a day over 360 is the Earth's
// orbital motion, which is the whole reason a sidereal day is four minutes
// short of a solar one.
const gmst = mod(280.46061837 + 360.98564736629 * (jd - J2000) + 0.000387933 * t * t, 360);
const hourAngle = (gmst + lng) * DEG - rightAscension;
const phi = lat * DEG;
const sinAltitude = clamp(
Math.sin(phi) * Math.sin(declination) +
Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle),
-1,
1,
);
let altitude = Math.asin(sinAltitude);
const azimuth = Math.atan2(
-Math.cos(declination) * Math.sin(hourAngle),
Math.sin(declination) * Math.cos(phi) - Math.cos(declination) * Math.sin(phi) * Math.cos(hourAngle),
);
altitude -= Math.asin(EARTH_RADIUS_KM / distanceKm) * Math.cos(altitude);
// Phase from the sun-moon elongation. The proper phase angle also wants the
// earth-sun distance, which moves the answer by about a sixth of a degree —
// two parts in a thousand of the illuminated fraction, and this drives a
// light rig.
const separation = mod(longitude - sunLongitude, 360);
return {
azimuth: mod(azimuth * RAD, 360),
elevation: altitude * RAD,
illuminated: (1 - Math.cos(separation * DEG)) / 2,
phase: separation / 360,
distanceKm,
};
}
// ---- Options --------------------------------------------------------------
@@ -178,6 +366,45 @@ export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
visibilityM: 5000,
};
/**
* Moonlight as a look rather than as a photometry.
*
* Full moonlight is about one four-hundred-thousandth of sunlight. Reproducing
* that ratio faithfully gives you a black screen, because a monitor has three
* orders of magnitude of range and this needs six, and because the eye that
* makes a moonlit landscape legible is doing an hour of dark adaptation that a
* lit room will not allow. So the numbers here are not the ratio; they are what
* a moonlit night *looks like* once you are in it — a low, soft, blue-shifted
* key you can read shapes by, on a sky that is deep blue rather than absent.
*
* The blue is the interesting lie. Moonlight is reflected sunlight off a
* grey-brown rock and is very slightly *warmer* than daylight, around 4,100 K.
* It looks blue because at those levels the eye is running on rods, whose peak
* sensitivity sits about 50 nm bluer than the cones' — the Purkinje shift — so
* a moonlit scene genuinely is blue to the person standing in it while being
* neutral to a light meter. Rendering it neutral is the more accurate choice
* and the wrong one, and every cinematographer since the 1930s has agreed.
*/
export interface MoonlightOptions {
/** Key intensity of a full moon, high, in clear air. Compare a noon sun at 2.1. */
intensity: number;
/** The key's colour. */
color: number;
/** How far a full moon lifts the night sky toward moonlit blue, 0..1. */
skyLift: number;
}
export const DEFAULT_MOONLIGHT: MoonlightOptions = {
// Physically absurd — moonlight is about 1/400,000 of sunlight — and the
// right number anyway. What is being reproduced is the *look* of a moonlit
// night on a screen someone is looking at in a lit room, not the photon
// count. At the honest value the map is a black rectangle, which is the bug
// this exists to fix.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
};
export interface AtmosphereOptions {
/**
* Observer longitude, degrees east. Needed for apparent solar time, which is
@@ -216,6 +443,13 @@ export interface AtmosphereOptions {
shadowFloorDeg?: number;
/** Coastal fog model, or nothing. Off unless a city asks for it. */
marineLayer?: MarineLayerOptions | null;
/**
* Moonlight, or `null` for none — which leaves the keyframe table's token
* night sidelight in charge, as it was before there was a moon to replace it.
* Defaults to `DEFAULT_MOONLIGHT`, because a black night is the failure and
* having to opt out of the fix is the wrong way round.
*/
moonlight?: MoonlightOptions | null;
}
export interface Atmosphere {
@@ -236,6 +470,26 @@ const DEFAULT_FOG_FAR = 460;
const DEFAULT_MIN_VISIBILITY_M = 4500;
const DEFAULT_SHADOW_FLOOR_DEG = 7;
/**
* The darkest the night sky is ever allowed to get, per channel.
*
* A correct night is `#000`, and `#000` is unusable: the horizon disappears,
* the skyline stops having a silhouette against anything, and the frame reads
* as a failed render rather than as darkness. These are the values of a clear
* moonless sky as a dark-adapted eye reports it rather than as a photometer
* does — still unmistakably night, with the horizon a little warmer and
* brighter than the zenith because that is where the airglow and everyone
* else's city lights are.
*/
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;
/** The same sky with a full moon in it. */
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x2b3b60;
const MOONLIT_AMBIENT = 0x3f4c76;
/**
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
* 0.457 and looks right, so the ratio is held rather than the distance: a fog
@@ -456,6 +710,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
const marineOptions = options.marineLayer ?? null;
const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight;
function apply(env: Environment): LightingState {
const elevation = env.sun.elevation;
@@ -468,23 +723,37 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
// still black, and a modifier that does not know the difference will
// cheerfully raise the small hours to a uniform slate.
const day = smoothstep(-6, 6, elevation);
const night = nightFactor(elevation);
const weather = env.weather;
const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
const condition = weather?.condition ?? "clear";
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
// An observation always beats the climatology: `null` lets the model run
// free, which is what gives an offline San Francisco its summer fog, but a
// station reporting sunshine ends the argument. See `observedObscuration`.
//
// Computed here rather than after the rig passes, where it used to sit,
// because the moon needs to know how much air is in the way before it can
// say how much light is getting through. Both halves are pure functions of
// the observation and neither touches the rig, so the move is a reordering
// of independent statements and nothing else.
const observed = weather ? observedObscuration(weather) : null;
const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
const obscuration =
observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled);
// The moon goes in before the weather does, so that an overcast night is
// the weather closing over a moonlit sky rather than over a black one.
const moon = moonOptions
? moonRig(env.moon, night, cloud, obscuration, moonOptions, shadowFloor)
: NO_MOON;
applyNight(rig, moon, night, moonOptions);
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit);
if (weather === null || weather.visibilityKm === null) {
// Rain shortens the view; a source that measured visibility has already
@@ -505,11 +774,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
return {
sun: {
direction: lightDirection(env.sun, shadowFloor),
color: rig.sunColor,
intensity: Math.max(0, rig.sunIntensity),
},
sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
hemisphere: {
sky: rig.hemiSky,
ground: rig.hemiGround,
@@ -563,6 +828,190 @@ function lightDirection(sun: SolarPosition, floorDeg: number): [number, number,
return [dir.x, dir.y, dir.z];
}
// ---- Night ----------------------------------------------------------------
/**
* How much of a night it is, from the sun alone: 0 in daylight, 1 once the
* sun's own light is gone.
*
* Exported because it is the engine's single definition of dusk, and more than
* one thing needs one. The moon takes over as the key light on this curve, and
* `nightlights.ts` reads it to decide how brightly a lit window burns against
* the sky behind it. Two modules each inventing their own idea of when night
* begins is how a city ends up switching its lights on well after the moon has
* already become the brightest thing in the frame.
*
* The upper edge is half a degree *above* the horizon rather than on it,
* because the sun's last half degree is its own disc setting and the light has
* already collapsed by then; the lower edge is the middle of nautical twilight,
* by which point what is left of the sun is a glow in one direction and not a
* light source.
*/
export function nightFactor(elevation: number): number {
return 1 - smoothstep(-8, 0.5, elevation);
}
/** The moon's contribution, once phase, altitude and the weather have had a say. */
interface MoonRig {
/** Unit vector toward the moon, floored like the sun's. `null` when it is down. */
direction: [number, number, number] | null;
color: number;
/** Key intensity. */
intensity: number;
/** How moonlit the sky and the fill are, 0..1. */
glow: number;
}
const NO_MOON: MoonRig = { direction: null, color: 0, intensity: 0, glow: 0 };
function moonRig(
moon: MoonPosition,
night: number,
cloud: number,
obscuration: number,
options: MoonlightOptions,
floorDeg: number,
): MoonRig {
// Below the horizon it contributes nothing, and the ramp above it is several
// degrees wide: a moon in the first degrees of its own rise is being
// reddened and extinguished by the same long air path that does it to the
// sun, and it has far less to lose.
const up = smoothstep(-1, 8, moon.elevation);
if (up <= 0 || night <= 0) return NO_MOON;
// Not linear in the illuminated fraction, and this is the part that makes a
// moon phase read as a moon phase. A half moon is nowhere near half as bright
// as a full one — nearer a tenth. Two things do that: at quarter phase the
// ground you can see is lit at a grazing angle and is mostly its own long
// shadows, and at full phase those shadows all hide behind the rocks casting
// them and the disc surges. The exponent is the cheap version of both. The
// floor is earthshine, and the fact that a key of exactly zero leaves a scene
// with no silhouettes in it at all.
const lit = 0.06 + 0.94 * clamp(moon.illuminated, 0, 1) ** 1.6;
// Cloud takes the key away much faster than it takes the glow away, and that
// asymmetry is the whole character of an overcast night: no shadows at all,
// but a deck lit from above that is brighter than a clear moonless sky.
const key = night * up * lit * clamp((1 - 0.9 * cloud) * (1 - 0.92 * obscuration), 0, 1);
const glow = night * up * lit * clamp(1 - 0.35 * cloud, 0, 1);
const lifted = floorDeg > 0 ? Math.max(moon.elevation, floorDeg) : moon.elevation;
return {
direction: skyDirection(moon.azimuth, lifted),
color: options.color,
intensity: options.intensity * key,
glow: clamp(glow, 0, 1),
};
}
/**
* Horizontal coordinates to the engine's axes: `x` east, `z` south, `y` up.
*
* `solar.ts` exports `sunDirection` for exactly this conversion and it would
* work unchanged on the moon — it reads only an azimuth and an elevation — but
* its argument is a `SolarPosition`, and inventing a declination and an
* equation of time to satisfy a type is worse than four lines that say what
* they mean. The convention is `solar.ts`'s and must stay it: azimuth is
* measured from north, and north is `-z`.
*/
function skyDirection(azimuth: number, elevation: number): [number, number, number] {
const el = elevation * DEG;
const az = azimuth * DEG;
const horizontal = Math.cos(el);
return [horizontal * Math.sin(az), Math.sin(el), -horizontal * Math.cos(az)];
}
/**
* Fold the night into the rig: the moon's fill, the residual glow of a moonless
* night, and the floor under the sky.
*
* The key itself is not set here — see `combineKey` — because a `LightingState`
* carries one directional light and the sun has not necessarily finished with
* it yet.
*/
function applyNight(
rig: Rig,
moon: MoonRig,
night: number,
options: MoonlightOptions | null,
): void {
if (night <= 0) return;
if (options) {
// The table's deepest stops carry a token sidelight standing in for
// moonlight and city glow, because a scene lit by hemisphere alone has no
// silhouettes in it and reads as a bug. There is a real moon now, so the
// stand-in gets out of its way — not entirely, because something still has
// to hold the shape of the city up on an overcast night at new moon.
rig.sunIntensity *= 1 - 0.85 * night;
const glow = clamp(moon.glow * options.skyLift, 0, 1);
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
rig.hemiIntensity *= 1 + 0.5 * glow;
rig.ambientIntensity *= 1 + 0.45 * glow;
}
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
// is overhead. Small, unshaped, and the difference between a night that is
// dark and a night that is missing.
rig.hemiIntensity *= 1 + 0.28 * night;
rig.ambientIntensity *= 1 + 0.22 * night;
// Upward only and per channel, so this can rescue a sky and can never dim
// one — the weather passes that follow are free to keep taking light out of
// the frame without having to know this ran.
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
}
/**
* One directional light, two things in the sky.
*
* `LightingState` carries a single key and that is the right shape — a second
* shadow-casting light is a second shadow map and a second full pass over 24k
* instances, for a source that is a two-hundred-thousandth as bright as the one
* already there. So the two get averaged, weighted by their own intensities,
* which is what a single light standing in for both ought to do: at dusk with a
* bright moon already up the key points somewhere between them, and by the time
* either one dominates it has arrived at that one. The crossover happens inside
* civil twilight, where both are near a tenth of an intensity and there is
* almost no shadow left to be wrong about.
*/
function combineKey(
sunDir: [number, number, number],
sunColor: number,
sunIntensity: number,
moon: MoonRig,
): LightingState["sun"] {
const sun = Math.max(0, sunIntensity);
if (!moon.direction || moon.intensity <= 0) {
return { direction: sunDir, color: sunColor, intensity: sun };
}
const total = sun + moon.intensity;
const weight = moon.intensity / total;
const x = lerp(sunDir[0], moon.direction[0], weight);
const y = lerp(sunDir[1], moon.direction[1], weight);
const z = lerp(sunDir[2], moon.direction[2], weight);
const length = Math.hypot(x, y, z);
// A full moon rises as the sun sets and the two stand opposite each other,
// which is the one configuration where the average of the two directions is
// nothing at all. A zero direction would put the light inside the ground and
// black the scene out, so take the brighter of the two instead.
const direction: [number, number, number] =
length < 0.05
? weight >= 0.5
? moon.direction
: sunDir
: [x / length, y / length, z / length];
return { direction, color: mixHex(sunColor, moon.color, weight), intensity: total };
}
// ---- Weather --------------------------------------------------------------
/**
@@ -847,6 +1296,21 @@ function desaturate(hex: number, t: number): number {
return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k));
}
/**
* Per-channel maximum, in linear light: a colour raised to a floor and never
* pushed below it.
*
* Per channel rather than by luminance, because the floor is a *colour* — a
* blue-black — and clamping a night sky by its brightness alone would let a
* grey of the same luminance through, which is the one thing the night must not
* be allowed to look like.
*/
function atLeast(hex: number, floor: number): number {
const [r, g, b] = linear(hex);
const [fr, fg, fb] = linear(floor);
return encode(Math.max(r, fr), Math.max(g, fg), Math.max(b, fb));
}
/** Multiply a colour's light, not its bytes. */
function scale(hex: number, factor: number): number {
const [r, g, b] = linear(hex);
@@ -923,10 +1387,13 @@ function wrapSigned(x: number, period: number): number {
* back a clear golden morning or it is not a model of anything — October is
* the month San Francisco is warm and cloudless and every visitor is
* surprised by it.
* - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the
* light direction's `y` pinned at 0.122, which is sin 7° — the shadow
* floor, keeping the token night sidelight from shining up through the
* ground.
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* sin 7° — the shadow floor, keeping what is left of the token night
* sidelight from shining up through the ground. The key is a thousandth
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it; the sky is a fog grey rather than a night blue for the same
* reason, which is right — a foggy night has no stars in it either.
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
* the palette's own. Out of season, the layer is not there.
*
@@ -952,4 +1419,39 @@ function wrapSigned(x: number, period: number): number {
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
* and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact.
*
* **At night, San Francisco with no weather and no marine layer**, so that the
* moon can be read on its own:
*
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
* seventh of the sun's noon intensity against nearly half of its fill,
* which is a soft directional key with shadows you can find and not trip
* over — on a sky that is unmistakably night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
* of the table's own sidelight colour, and the sky lands on #090e1c over
* #16203a — the floor, exactly. That is the darkest frame this file can
* produce, and it is the point: a genuinely correct night is `#000` and
* `#000` is a bug report.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
* the full moon's key from half its disc and a tenth of its altitude,
* which is the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
* all of the colour, and August is when it would.
* - **The same night reported overcast**: key 0.032 — no shadows at all —
* with the fill barely down, because a cloud deck over a full moon is a
* softbox rather than a lid.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
* colour #2e3c66, the table's token sidelight left in charge. The night
* floor under the sky still applies, because that one is not about the
* moon.
*
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
* configuration `combineKey` exists for — a full moon rising as the sun sets,
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact — it is
* what actually happens, and on the one night a month it happens on.
*/
+50
View File
@@ -31,6 +31,36 @@ const PALETTES = {
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;
/**
* How commercial each palette's buildings are, 0..1.
*
* Read only by `nightlights.ts`, and the reason a night city looks like a city
* rather than like a uniform field of dots: an office floor is a continuous
* band of large windows with half of them left on all night, and a house is two
* small warm rectangles that go out. The number is the same fact the palette
* already encodes, which is why it is derived from it rather than authored
* again per district.
*/
const COMMERCIAL = {
downtown: 1,
residential: 0.12,
industrial: 0.45,
} satisfies Record<District["palette"], number>;
/**
* The name of the per-instance attribute `createBlocks` leaves on its geometry:
* `[commercial, seed]`.
*
* A vertex attribute rather than a field on `userData` because the only
* consumer is a shader, and this puts the data where the GPU already wants it.
* `createBlocks` writes it because `createBlocks` is what knows which district
* a given instance came out of; nothing else can recover that from the mesh.
*/
export const FACADE_ATTRIBUTE = "aFacade";
/** An independent stream for the facades; see where it is drawn from. */
const FACADE_SEED = 20_261;
interface Box {
x: number;
z: number;
@@ -40,6 +70,7 @@ interface Box {
h: number;
rot: number;
color: THREE.Color;
commercial: number;
}
function polygonBounds(poly: [number, number][]) {
@@ -65,6 +96,7 @@ export function createBlocks(world: World): THREE.InstancedMesh {
seedBase += 7919;
const palette = PALETTES[district.palette];
const commercial = COMMERCIAL[district.palette];
const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88;
@@ -124,6 +156,8 @@ export function createBlocks(world: World): THREE.InstancedMesh {
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
// A tower is an office whatever district it landed in.
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
});
}
}
@@ -132,6 +166,22 @@ export function createBlocks(world: World): THREE.InstancedMesh {
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
// The per-instance facade data, drawn from a stream of its own.
//
// The obvious place for the seed is inside the placement loop, next to every
// other `rand()` — and putting it there would have been a mistake, because a
// scatter's draw sequence is load-bearing. One extra call shifts every
// subsequent draw, and the whole city would have rebuilt itself the first
// time anyone lit a window. A second stream costs nothing, is just as
// deterministic across reloads, and leaves the skyline exactly where it was.
const windows = seededRandom(FACADE_SEED);
const facade = new Float32Array(boxes.length * 2);
boxes.forEach((b, i) => {
facade[i * 2] = b.commercial;
facade[i * 2 + 1] = windows();
});
geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2));
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
mesh.castShadow = true;
+439 -31
View File
@@ -15,6 +15,7 @@
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Aircraft, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
@@ -54,20 +55,33 @@ export class SimulatedFlights implements FlightSource {
this.t += Math.min(now - this.last, 5);
this.last = now;
return this.routes.map((route, i) => {
const p = ((this.t / route.duration + (this.phase[i] ?? 0)) % 1 + 1) % 1;
const lat = route.from[0] + (route.to[0] - route.from[0]) * p;
const lng = route.from[1] + (route.to[1] - route.from[1]) * p;
// Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - p) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
});
return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
}
}
/**
* One aircraft's state at a fraction of the way along its leg. `p` wraps, so
* anything can be handed in and 1.4 means the same as 0.4.
*
* Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly
* this and cannot reuse the class to get it: `SimulatedFlights` runs on a
* monotonic clock that starts when it is constructed, whereas the wire's
* `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers
* agree about where the aircraft are. Same arithmetic, different origin — and
* two copies of the arithmetic would drift.
*/
export function sampleRoute(route: SimRoute, p: number): Aircraft {
const t = ((p % 1) + 1) % 1;
const lat = route.from[0] + (route.to[0] - route.from[0]) * t;
const lng = route.from[1] + (route.to[1] - route.from[1]) * t;
// Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - t) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
}
function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}
@@ -125,53 +139,447 @@ interface RawAircraft {
export interface FlightLayer {
group: THREE.Group;
/**
* Hand over a fresh observation. Called on the source's own timer, which is
* once a second for the simulator and once every several seconds for a real
* feed; the motion in between is this layer's problem, not the caller's.
*/
update(aircraft: Aircraft[]): void;
/**
* Move everything to where it should be at this instant.
*
* A pure function of the wall clock and the last two observations, so calling
* it twice in a frame does the same thing as calling it once. That matters:
* the layer drives itself from the trail geometry's `onBeforeRender` — see
* `createFlightLayer` — and a scene that also ticks it explicitly must not end
* up advancing time twice as fast.
*/
tick(): void;
dispose(): void;
}
/**
* Aircraft as small darts with a shadow-less trail. Rendered at true altitude
* through the world's vertical exaggeration, so a jet on approach sits visibly
* below one at cruise.
* How many observations a trail remembers, and how long it may hold one.
*
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
* the age keeps a slow feed from drawing a trail across the entire bay: at
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
* of flying, which is most of a leg.
*/
const TRAIL_POINTS = 20;
const TRAIL_SECONDS = 45;
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
const MAX_TRACKS = 192;
/**
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1
* on purpose: the trail is context for the dart, not a second subject, and a
* dozen opaque lines over a city read as a wiring diagram.
*/
const TRAIL_ALPHA = 0.55;
/**
* Bounds on how long a leg between two observations may be taken to be.
*
* The span is measured rather than declared, because a `FlightSource` announces
* an `interval` and then misses it — a tab in the background, a slow upstream,
* a fetch that took two seconds. Interpolating over the announced interval when
* the real gap was four times that gives an aircraft that darts and then waits.
*/
const MIN_SPAN = 0.2;
const MAX_SPAN = 15;
/**
* Above this, a step is a teleport rather than a flight.
*
* Scene units per second, and generous: a fast jet at this city's ~94 m per
* unit covers about three. The case this exists for is the simulator's routes
* looping — an aircraft reaching the end of its leg reappears at the start,
* which is several hundred units in one poll — and without the check the trail
* draws a bright line straight across San Francisco every time one wraps.
*/
const JUMP_UNITS_PER_SECOND = 8;
/**
* Altitude, as colour.
*
* The obvious cue is a drop line to the ground, and it was tried first and
* removed: this city renders at ~94 m per scene unit with a 3.6× vertical
* exaggeration, so an aircraft at cruise sits about 230 units above a downtown
* whose tallest tower is 10, and its drop line is a full-height wire through the
* middle of the frame. Twelve of those is a birdcage. Colour costs nothing, is
* readable at any camera distance, and — because the trail carries it too — a
* climb shows up as a gradient along the ribbon rather than as a number nobody
* reads.
*/
const LOW_COLOR = new THREE.Color(0xffb277);
const HIGH_COLOR = new THREE.Color(0xdfeaf6);
/** Metres at which the ramp reaches `HIGH_COLOR`. Roughly a cruising airliner. */
const CRUISE_METRES = 9000;
/** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */
const COLOR_BANDS = 12;
/** Steepest nose-up or nose-down attitude a dart is drawn at, in radians. */
const MAX_PITCH = 0.42;
interface TrailSample {
position: THREE.Vector3;
altitude: number;
/** Compass degrees, as reported. */
heading: number;
/** Seconds on `nowSeconds`'s monotonic clock. */
at: number;
}
interface Track {
mesh: THREE.Mesh;
/** Observations, oldest first. The last is where the aircraft is heading. */
samples: TrailSample[];
/** Seconds the current leg should take: the measured gap between the last two. */
span: number;
/** Climb angle of the current leg, radians, positive nose-up. */
pitch: number;
/** Which cached material is on the mesh, so a band change is the only write. */
band: number;
/** Interpolated position, reused rather than reallocated every frame. */
head: THREE.Vector3;
/** Altitude at `head`, which is what the dart's colour is chosen from. */
headAltitude: number;
}
/**
* Aircraft as small darts, each dragging a fading trail of where it has been.
*
* Rendered at true altitude through the world's vertical exaggeration, so a jet
* on approach sits visibly below one at cruise, and coloured by that altitude so
* the difference survives a camera far enough away that the heights stop being
* separable.
*
* The layer moves things every frame while being told where they are only every
* poll. Positions are interpolated between the last two observations rather than
* extrapolated past the newest one: that costs one interval of lag — a second
* for the simulator — and in exchange an aircraft never overshoots and then
* snaps back, which is what extrapolation does the moment a feed stutters.
*/
export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group();
group.name = "flights";
const geo = new THREE.ConeGeometry(0.1, 0.42, 5);
geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation
const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 });
const meshes = new Map<string, THREE.Mesh>();
const geo = dartGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
const tracks = new Map<string, Track>();
const scratch = new THREE.Color();
/**
* One material per altitude band, built on demand.
*
* The emissive term is small and deliberate. Aircraft are lit by the same rig
* as the city, and after sunset that rig is a tenth of an intensity — a dart
* of pure diffuse white simply disappears at night, which is the one time of
* day the sky is worth looking at.
*/
function materialFor(band: number): THREE.MeshLambertMaterial {
const existing = materials.get(band);
if (existing) return existing;
const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex();
const mat = new THREE.MeshLambertMaterial({
color,
emissive: color,
emissiveIntensity: 0.35,
});
materials.set(band, mat);
return mat;
}
// ---- The trail ----------------------------------------------------------
// One `LineSegments` for every trail in the scene rather than one per
// aircraft: the vertex count is trivial either way, and a single draw call
// with a preallocated buffer avoids allocating and disposing geometry every
// time traffic changes. Per-vertex alpha does the fade, which needs a
// four-component colour attribute — three.js reads the item size and switches
// the shader on it.
const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2;
const trailPositions = new Float32Array(maxVertices * 3);
const trailColors = new Float32Array(maxVertices * 4);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3));
trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4));
trailGeo.setDrawRange(0, 0);
const trailMat = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
// Trails cross each other constantly and are the faintest thing in the
// scene; letting them write depth makes the one that happened to draw first
// punch a hole in every one behind it.
depthWrite: false,
});
const trailLine = new THREE.LineSegments(trailGeo, trailMat);
trailLine.name = "flight-trails";
// The buffer is rewritten from scene-space coordinates every frame, so its
// bounding sphere is permanently wrong and culling it would be culling the
// whole layer.
trailLine.frustumCulled = false;
// The layer is handed observations on the source's timer and is otherwise
// never called, so the interpolation hangs off the one thing guaranteed to
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
// that would rather drive the layer itself can call it and nothing here
// double-counts.
trailLine.onBeforeRender = () => tick();
group.add(trailLine);
// ---- Observations -------------------------------------------------------
function update(aircraft: Aircraft[]) {
const now = nowSeconds();
const seen = new Set<string>();
for (const a of aircraft) {
seen.add(a.id);
let mesh = meshes.get(a.id);
if (!mesh) {
mesh = new THREE.Mesh(geo, material);
meshes.set(a.id, mesh);
group.add(mesh);
}
const [x, z] = world.project(a.lat, a.lng);
mesh.position.set(x, world.metres(a.altitude), z);
mesh.rotation.y = -(a.heading * Math.PI) / 180;
const position = new THREE.Vector3(x, world.metres(a.altitude), z);
const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now };
let track = tracks.get(a.id);
if (!track) {
const mesh = new THREE.Mesh(geo, materialFor(0));
// Yaw then pitch, because the heading is about the world's vertical and
// the climb angle is about the aircraft's own wing.
mesh.rotation.order = "YXZ";
group.add(mesh);
track = {
mesh,
samples: [],
span: MIN_SPAN,
pitch: 0,
band: -1,
head: position.clone(),
headAltitude: a.altitude,
};
tracks.set(a.id, track);
}
const previous = track.samples[track.samples.length - 1];
if (previous) {
// The clamp is load-bearing on both ends. Two polls arriving in the same
// millisecond — a manual refresh, a tab waking up — divide by nearly
// zero and make every aircraft look like it teleported; a source that
// stalled for a minute makes the next honest step look like one too.
const span = clamp(now - previous.at, MIN_SPAN, MAX_SPAN);
// Ground distance only. Scene height is exaggerated 3.6× here, so a
// healthy climb contributes more to a straight 3-D distance than the
// aircraft's actual speed does, and a departure out of SFO would trip
// the teleport test on every poll.
const travelled = Math.hypot(
position.x - previous.position.x,
position.z - previous.position.z,
);
if (travelled / span > JUMP_UNITS_PER_SECOND) {
// A source that has moved something further than anything flies has
// either looped a simulated route or reused an id. Either way the
// history is about a different flight; keeping it would draw a trail
// across the map.
track.samples.length = 0;
track.head.copy(position);
track.pitch = 0;
} else {
track.span = span;
track.pitch = climbAngle(world, previous, sample);
}
}
track.samples.push(sample);
trim(track, now);
}
for (const [id, mesh] of meshes) {
for (const [id, track] of tracks) {
if (seen.has(id)) continue;
group.remove(mesh);
meshes.delete(id);
group.remove(track.mesh);
tracks.delete(id);
}
tick();
}
/** Forget history that is too old or too long to be worth drawing. */
function trim(track: Track, now: number) {
while (track.samples.length > TRAIL_POINTS) track.samples.shift();
while (track.samples.length > 2) {
const oldest = track.samples[0];
if (!oldest || now - oldest.at <= TRAIL_SECONDS) break;
track.samples.shift();
}
}
// ---- Per-frame ----------------------------------------------------------
function tick() {
const now = nowSeconds();
for (const track of tracks.values()) {
const n = track.samples.length;
const to = track.samples[n - 1];
if (!to) continue;
const from = track.samples[n - 2] ?? to;
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
track.mesh.position.copy(track.head);
// A heading of 0 is north, and north is -z, so a dart whose nose is
// modelled along +z has to be turned all the way round before the compass
// and the scene agree. The previous mapping was a bare negation of the
// heading, which flew every aircraft tail-first and put an easterly
// departure over the Pacific.
track.mesh.rotation.y = Math.PI - (interpolateHeading(from.heading, to.heading, alpha) * Math.PI) / 180;
// Negative, because rotating the nose (+z) about +x by a positive angle
// pushes it down.
track.mesh.rotation.x = -track.pitch;
const band = bandFor(track.headAltitude);
if (band !== track.band) {
track.band = band;
track.mesh.material = materialFor(band);
}
}
rebuildTrails();
}
/**
* Rewrite the shared trail buffer.
*
* The spine is every observation except the newest, followed by the
* interpolated head — the newest observation is where the aircraft is *going*,
* and drawing to it would put the trail in front of the dart.
*/
function rebuildTrails() {
let vertex = 0;
for (const track of tracks.values()) {
const spine = track.samples.length - 1;
if (spine < 1) continue;
const points = spine + 1; // the spine, plus the head
for (let i = 1; i < points; i++) {
if (vertex + 2 > maxVertices) break;
const a = track.samples[i - 1];
if (!a) continue;
const bSample = i < spine ? track.samples[i] : null;
const bPosition = bSample ? bSample.position : track.head;
const bAltitude = bSample ? bSample.altitude : track.headAltitude;
// Alpha runs from nothing at the tail to `TRAIL_ALPHA` at the aircraft,
// eased so that the fade happens mostly in the older half and the
// segment behind the dart stays legible.
writeTrailVertex(vertex++, a.position, a.altitude, ((i - 1) / spine) ** 1.7);
writeTrailVertex(vertex++, bPosition, bAltitude, (i / spine) ** 1.7);
}
}
trailGeo.setDrawRange(0, vertex);
trailGeo.attributes.position!.needsUpdate = true;
trailGeo.attributes.color!.needsUpdate = true;
}
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
const p = index * 3;
trailPositions[p] = position.x;
trailPositions[p + 1] = position.y;
trailPositions[p + 2] = position.z;
// `THREE.Color` holds working-space values, which is what a vertex colour
// attribute is read as — so the ramp and the dart materials, which come from
// the same two colours, agree.
scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, ramp(altitude));
const c = index * 4;
trailColors[c] = scratch.r;
trailColors[c + 1] = scratch.g;
trailColors[c + 2] = scratch.b;
trailColors[c + 3] = fade * TRAIL_ALPHA;
}
return {
group,
update,
tick,
dispose() {
geo.dispose();
material.dispose();
meshes.clear();
for (const m of materials.values()) m.dispose();
materials.clear();
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
group.clear();
},
};
}
/**
* A dart: a five-sided body with a wing and a tailplane, merged into one
* geometry so an aircraft is one draw call.
*
* The wing is what earns its keep. A bare cone at this scale is a bright speck
* with no orientation, and the whole reason to draw traffic on a city map is
* that it is going somewhere — the crossbar is the only part of the silhouette
* that says which way.
*/
function dartGeometry(): THREE.BufferGeometry {
const body = new THREE.ConeGeometry(0.09, 0.42, 5);
body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y
const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085);
wing.translate(0, -0.005, -0.02);
const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055);
tail.translate(0, 0.02, -0.165);
const parts = [body, wing, tail];
const merged = mergeGeometries(parts);
for (const part of parts) part.dispose();
if (merged) return merged;
// `mergeGeometries` returns null when the inputs disagree about their
// attributes, which three primitives from the same library cannot — but the
// signature allows it, and a missing aircraft is worse than a plain one.
const fallback = new THREE.ConeGeometry(0.09, 0.42, 5);
fallback.rotateX(Math.PI / 2);
return fallback;
}
/**
* The climb angle of a leg, from the real numbers rather than the scene's.
*
* Scene height is exaggerated 3.6× here, so an angle measured off the rendered
* positions would put a routine departure at forty degrees nose-up. Horizontal
* distance in scene units *is* proportional to distance on the ground, so one
* multiplication converts it and the altitudes are already metres.
*/
function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
const dx = to.position.x - from.position.x;
const dz = to.position.z - from.position.z;
const horizontal = Math.hypot(dx, dz) * world.metresPerUnit;
if (horizontal < 1) return 0;
return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH);
}
/**
* Blend two compass headings the short way round.
*
* A straight lerp from 350° to 10° spins the aircraft 340° through south over
* the course of a second, which is the most conspicuous artefact this whole file
* could have.
*/
function interpolateHeading(from: number, to: number, t: number): number {
const delta = (((to - from) % 360) + 540) % 360 - 180;
return from + delta * t;
}
/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */
function ramp(altitude: number): number {
return clamp(altitude / CRUISE_METRES, 0, 1) ** 0.6;
}
function bandFor(altitude: number): number {
return Math.round(ramp(altitude) * (COLOR_BANDS - 1));
}
function clamp(x: number, lo: number, hi: number): number {
return x < lo ? lo : x > hi ? hi : x;
}
+572
View File
@@ -0,0 +1,572 @@
/**
* The city's own lights: lit windows in the buildings, and lamps along the
* streets.
*
* This is the other half of making a night usable. `atmosphere.ts` puts a moon
* up so there is something to see the city *by*; this puts light *in* the city,
* which is most of what a city at night actually is — from any distance a
* skyline after dark is not a shape you can make out, it is a field of small
* bright rectangles that happens to have a shape.
*
* Two constraints shaped everything here:
*
* - **There are ~24,000 buildings in one `InstancedMesh`.** A point light per
* building is not a slow version of this, it is an impossible one: three.js
* evaluates every light in the fragment shader for every lit surface, and
* the practical ceiling is a few dozen. So the buildings do not emit light
* at all. They *are* light — an emissive term added inside the existing
* material, which costs one shader patch and no extra draw calls, and which
* the moon and the fog and the shadows all continue to work around
* untouched.
* - **Nothing may reshuffle between frames or reloads.** Which windows are on
* is a hash of the window's own cell index and a per-instance seed drawn
* from `blocks.ts`'s seeded RNG, evaluated in the fragment shader. It is a
* pure function of position, so it is stable across frames for free, and it
* costs no memory at all: 24,000 buildings' worth of individual windows
* would be millions of booleans and there are none of them anywhere.
*
* **This is not a lighting owner.** `Atmosphere` owns the rig and CONTRACT.md §4
* is explicit that nothing else may touch it; what this module owns is
* *emission*, which is a property of the buildings and not of the light rig, and
* it never constructs a `THREE.Light` of any kind. The seam is one number in:
* `setSolarElevation`, which is the same solar elevation `Atmosphere` is
* reading. Night is data, not a mode flag, and there is nothing to switch.
*
* An interior gets none of this, for the same reason it gets no `Atmosphere`:
* an office has its own fixed rig and no idea what time it is outside.
*/
import * as THREE from "three";
import { nightFactor } from "./atmosphere.ts";
import { FACADE_ATTRIBUTE } from "./blocks.ts";
import { seededRandom, type World } from "./world.ts";
export interface NightLightsOptions {
world: World;
/** The buildings, exactly as `createBlocks` returned them. */
blocks: THREE.InstancedMesh;
/** Lamps along the road network. On by default. */
streetLamps?: boolean;
/** Metres between street lamps. */
lampSpacingM?: number;
/**
* Ceiling on how many lamps get built, as insurance against a city pack with
* a very dense road network. SF's twenty-five roads produce a few thousand.
*/
maxLamps?: number;
}
export interface NightLights {
/** Everything this layer adds to the scene. Added once, then driven. */
group: THREE.Group;
/**
* The seam. Hand it the solar elevation in degrees — the same number
* `Atmosphere` is working from — and the city switches itself on.
*/
setSolarElevation(degrees: number): void;
/** How on the lights currently are, 0..1. For a debug readout. */
strength(): number;
dispose(): void;
}
// ---- Constants ------------------------------------------------------------
/**
* A window bay, and a storey, in metres.
*
* The storey is real. The bay is not: a curtain wall's mullions are nearer 1.5 m
* apart, and at San Francisco's ~94 m per scene unit that is a third of a pixel
* from anywhere the camera is allowed to be, so an honest bay renders as grey
* noise and nothing else. 6.5 m is the coarsest grid that still reads as
* windows rather than as panels, which puts about six bays across a 40 m lot
* and gives a pane the wide flat shape of a ribbon window. Vertically there is
* no such problem — the city's 3.6x exaggeration makes a storey four times a
* bay on screen — so the storey stays honest.
*/
const WINDOW_PITCH_M = 6.5;
const STOREY_M = 3.6;
/**
* Fraction of windows left on, by how commercial the building is.
*
* Both are lower than they look, and deliberately: the aggregate is what the
* eye reads, and the first pass at this — half of every office window on — came
* out as a city of solid glowing slabs with no building shapes left in it. A
* quarter is already a *lot* of light once every pane is at nearly full
* emission, and a house showing one window in sixteen is a street with somebody
* still up on it.
*/
const HOUSE_LIT = 0.06;
const OFFICE_LIT = 0.24;
/**
* The two colours a lit window comes in.
*
* Warm is a domestic lamp — tungsten, or the LED everyone buys because it looks
* like tungsten — at something like 2,700 K. Cool is an office ceiling left on
* by the cleaners, which is the other half of any real skyline and the half
* that makes the warm windows read as warm. Passed as `THREE.Color`, so three
* converts them out of sRGB into the linear working space on the way to the
* uniform and the emissive term lands in the same space as everything else in
* the shader.
*/
const WINDOW_WARM = 0xffc178;
const WINDOW_COOL = 0xd8e4ff;
/** Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. */
const WINDOW_GAIN = 0.8;
/** Sodium, because a street lamp is the one light in a city that still is. */
const LAMP_COLOR = 0xffb264;
const LAMP_HEIGHT_M = 9;
const DEFAULT_LAMP_SPACING_M = 55;
const DEFAULT_MAX_LAMPS = 24_000;
/**
* Glow radius of a lamp, in scene units.
*
* Chosen against the far end of the camera's orbit rather than the near end. At
* 100 units out — the framing the city is usually looked at from — this is a
* few pixels, which is what a street lamp is; flying down to the 12-unit
* minimum blooms it to something much larger than a lamp. That is the wrong way
* round from a purist's point of view and the right way round for the frame
* anyone actually looks at, and the alternative — a fixed pixel size — turns the
* whole road network into a sheet of aliasing sparkle the moment you pull back.
*/
const LAMP_SIZE = 0.3;
const LAMP_SEED = 61_803;
/**
* When the lamps come on, in degrees of solar elevation.
*
* Earlier than `nightFactor`, and deliberately so: street lighting switches on
* around sunset, an hour before the sky is dark, and offices have been lit
* since the afternoon. What `nightFactor` then adds is not more lights but more
* *contrast* — the same windows against a sky that has stopped competing with
* them. Multiplying the two is what produces the real sequence, where the city
* appears to come on gradually over an hour without anything ever switching.
*/
const LAMPS_ON_HIGH = 5;
const LAMPS_ON_LOW = -5;
/** Below this the layer is hidden outright rather than drawn at zero. */
const DARK_ENOUGH = 0.002;
// ---- The layer ------------------------------------------------------------
export function createNightLights(options: NightLightsOptions): NightLights {
const { world, blocks } = options;
const group = new THREE.Group();
group.name = "nightlights";
// Shared with the shader by reference: `onBeforeCompile` hands these exact
// objects to the program, so writing `.value` here is what drives the frame.
const uniforms = {
uNight: { value: 0 },
uWindowPitch: { value: WINDOW_PITCH_M / world.metresPerUnit },
// A storey goes through `world.metres`, so it picks up the city's vertical
// exaggeration exactly as the building's own height did. Without that the
// floor count would be wrong by the exaggeration factor — a 100 m tower
// would come out with a hundred floors in it.
uStorey: { value: world.metres(STOREY_M) },
uWarm: { value: new THREE.Color(WINDOW_WARM) },
uCool: { value: new THREE.Color(WINDOW_COOL) },
uGain: { value: WINDOW_GAIN },
uHouseLit: { value: HOUSE_LIT },
uOfficeLit: { value: OFFICE_LIT },
};
const facade = blocks.geometry.getAttribute(FACADE_ATTRIBUTE);
const material = blocks.material;
const patched =
!Array.isArray(material) && material instanceof THREE.MeshLambertMaterial && facade
? patchFacades(material, uniforms)
: null;
const lamps = (options.streetLamps ?? true) ? buildLamps(world, options) : null;
if (lamps) group.add(lamps.points);
let strength = 0;
function setSolarElevation(degrees: number) {
// Two curves, multiplied: when the lights are on, and how much darker than
// them the sky is. See `LAMPS_ON_HIGH`.
const on = 1 - smoothstep(LAMPS_ON_LOW, LAMPS_ON_HIGH, degrees);
strength = on * (0.35 + 0.65 * nightFactor(degrees));
uniforms.uNight.value = strength;
if (lamps) {
lamps.points.visible = strength > DARK_ENOUGH;
lamps.material.opacity = strength;
}
}
setSolarElevation(90);
return {
group,
setSolarElevation,
strength: () => strength,
dispose() {
// The buildings are not ours and outlive this layer, so the material goes
// back exactly as it was found rather than being left with a dark
// uniform in it and a patch nobody remembers applying.
patched?.();
lamps?.points.geometry.dispose();
lamps?.material.map?.dispose();
lamps?.material.dispose();
group.clear();
},
};
}
// ---- Lit windows ----------------------------------------------------------
type Uniforms = Record<string, { value: unknown }>;
/**
* Add an emissive window grid to the buildings' own material.
*
* Patching in place rather than replacing the material, because `blocks.ts`
* owns what a facade looks like in daylight and this has no business having an
* opinion about that. Everything below is additive: a `totalEmissiveRadiance`
* term, computed after the lighting has been accumulated and before fog and the
* colour-space encode, so a lit window is correctly hazed by the marine layer
* and correctly *not* darkened by being in shadow. Which is right — a window is
* a hole with a light behind it, and nothing outside the building can shade it.
*
* Returns the undo.
*/
function patchFacades(material: THREE.MeshLambertMaterial, uniforms: Uniforms): () => void {
const previous = material.onBeforeCompile;
material.onBeforeCompile = (shader) => {
for (const [name, uniform] of Object.entries(uniforms)) {
shader.uniforms[name] = uniform as THREE.IUniform;
}
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
.replace("#include <project_vertex>", `#include <project_vertex>\n${VERTEX_BODY}`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
.replace(
"#include <emissivemap_fragment>",
`#include <emissivemap_fragment>\n${FRAGMENT_BODY}`,
);
};
// `Material.customProgramCacheKey` defaults to the source of
// `onBeforeCompile`, so the renderer will not hand this material a program
// compiled for an unpatched one. Changing the function is still a new
// program, hence the flag.
material.needsUpdate = true;
return () => {
material.onBeforeCompile = previous;
material.needsUpdate = true;
};
}
/**
* The varyings are declared unconditionally in both stages — a varying present
* in one and absent from the other is a link error — while the two things that
* only exist under instancing are guarded. `aFacade` needs no guard: an
* unbound vertex attribute reads as zero, which is a building with no windows
* lit, which is a perfectly good failure.
*/
const VERTEX_PARS = /* glsl */ `
attribute vec2 aFacade;
varying vec3 vFacadeLocal;
varying vec3 vFacadeNormal;
varying vec3 vFacadeSize;
varying vec2 vFacade;
`;
const VERTEX_BODY = /* glsl */ `
vFacadeLocal = transformed;
vFacadeNormal = objectNormal;
vFacade = aFacade;
#ifdef USE_INSTANCING
// The instance's scale, recovered from the columns of its own matrix. This is
// what puts the window grid in scene units instead of in fractions of a
// building: without it every tower would have the same number of floors as
// the bungalow next door, stretched to fit.
vFacadeSize = vec3(
length(instanceMatrix[0].xyz),
length(instanceMatrix[1].xyz),
length(instanceMatrix[2].xyz)
);
#else
vFacadeSize = vec3(1.0);
#endif
`;
const FRAGMENT_PARS = /* glsl */ `
uniform float uNight;
uniform float uWindowPitch;
uniform float uStorey;
uniform float uGain;
uniform float uHouseLit;
uniform float uOfficeLit;
uniform vec3 uWarm;
uniform vec3 uCool;
varying vec3 vFacadeLocal;
varying vec3 vFacadeNormal;
varying vec3 vFacadeSize;
varying vec2 vFacade;
float facadeHash(vec3 p) {
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}
`;
/**
* The window grid, and the reason it does not sparkle.
*
* A window bay is about 4 m, which at San Francisco's ~94 m per scene unit is
* 0.042 units — and from the distance the city is normally looked at, that is
* well under a pixel. Drawn honestly it would be a sheet of moiré that crawls
* whenever the camera moves, which is the classic failure of any procedural
* pattern with no mip chain behind it. `fwidth` gives the pattern's own
* footprint in pixels, and past about one cell per pixel the grid is replaced
* by its average — which is exactly what a mip level would have contained. So
* the far city is a smooth glow whose brightness is the density of its lit
* windows, downtown reads brighter than the avenues because it genuinely has
* more of them on, and flying in resolves individual windows out of it.
*/
const FRAGMENT_BODY = /* glsl */ `
if (uNight > 0.002) {
vec3 faceNormal = normalize(vFacadeNormal);
// Roofs have no windows in them, and this is a box.
float wall = 1.0 - smoothstep(0.55, 0.95, abs(faceNormal.y));
if (wall > 0.0) {
float across = abs(faceNormal.x) > 0.5
? vFacadeLocal.z * vFacadeSize.z
: vFacadeLocal.x * vFacadeSize.x;
float up = vFacadeLocal.y * vFacadeSize.y;
vec2 grid = vec2(across / uWindowPitch, up / uStorey);
vec2 cell = fract(grid);
vec2 pane = step(vec2(0.22, 0.34), cell) * step(cell, vec2(0.78, 0.72));
float coverage = pane.x * pane.y;
// Which windows are on: a hash of the cell and the building's own seed, so
// it is a pure function of where you are looking and never has to be
// stored, animated or reconciled.
float roll = facadeHash(vec3(floor(grid), vFacade.y * 137.0));
// How lit this particular building is, on top of what its district says.
// Without it every tower downtown has the same window density, and from a
// distance the whole financial district smears into one flat brown
// rectangle — which is the one thing a night skyline never looks like. The
// curve is squared so most buildings are dim and a few blaze, and scaled so
// that the mean of it is exactly 1 and the district's own figure still
// means what it says.
float variation = 0.15 + 2.55 * vFacade.y * vFacade.y;
float chance = clamp(mix(uHouseLit, uOfficeLit, vFacade.x) * variation, 0.0, 0.9);
float on = step(1.0 - chance, roll);
float footprint = max(fwidth(grid.x), fwidth(grid.y));
float detail = 1.0 - smoothstep(0.5, 1.4, footprint);
// 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's
// own mean — and 1.8 times that is what is actually used, which is a lie
// worth being explicit about. The mean is the right answer for a display
// whose response is linear, and no display's is: a pixel that in reality
// contains one small blazing window and three dark ones does not read to
// the eye as the average of the four, it reads as lit. With no HDR buffer
// and no bloom to arrive at that honestly, the multiplier is the cheap way
// to keep the far city as bright as the near city says it ought to be.
float glow = mix(1.8 * 0.2128 * chance, coverage * on, detail);
// Roughly seven windows in ten warm. A skyline is mostly people's lamps and
// only partly the floors the cleaners are still on.
//
// The colour needs the same averaging the mask got, and forgetting it is a
// subtle and very visible bug: a mask correctly resolved to its mean, tinted
// by a hard per-cell choice between two colours at a frequency far below one
// pixel, gives a distant city that is the right brightness and crawling with
// orange and white confetti.
vec3 tint = mix(mix(uWarm, uCool, 0.3), mix(uWarm, uCool, step(0.7, fract(roll * 7.13))), detail);
totalEmissiveRadiance += tint * (glow * wall * uNight * uGain);
}
}
`;
// ---- Street lamps ---------------------------------------------------------
interface Lamps {
points: THREE.Points;
material: THREE.PointsMaterial;
}
/**
* Lamps along the road network, as one additive point cloud.
*
* Cheap enough to be worth it: a few thousand points in a single draw call,
* with no lighting, no shadows and no per-frame work beyond an opacity. What
* they buy is the thing the buildings cannot — the *ground* has light on it, so
* the street grid is still legible at night and the city keeps the shape that
* makes it recognisable from above. In San Francisco that shape is the 46°
* between the grid north of Market and the grid south of it, and losing it
* after dark would lose the city.
*
* They emit nothing, of course. A real street lamp pooling light on the road
* under it is a second set of lights and a second shadow problem, and the
* pooling would be invisible at any framing where the lamp itself is a pixel.
*/
function buildLamps(world: World, options: NightLightsOptions): Lamps | null {
const spacing = (options.lampSpacingM ?? DEFAULT_LAMP_SPACING_M) / world.metresPerUnit;
const lift = world.metres(LAMP_HEIGHT_M);
const limit = options.maxLamps ?? DEFAULT_MAX_LAMPS;
const rand = seededRandom(LAMP_SEED);
const positions: number[] = [];
let index = 0;
for (const road of world.city.roads) {
// Carried across segment joins, so the spacing is even along the whole
// street rather than restarting at every corner — which would cluster
// lamps wherever a road was written with a lot of vertices in it, and
// those are exactly the bends.
let carry = 0;
for (let i = 0; i < road.path.length - 1; i++) {
const from = road.path[i];
const to = road.path[i + 1];
if (!from || !to) continue;
const [lat0, lng0] = from;
const [lat1, lng1] = to;
const [x0, z0] = world.project(lat0, lng0);
const [x1, z1] = world.project(lat1, lng1);
const dx = x1 - x0;
const dz = z1 - z0;
const length = Math.hypot(dx, dz);
if (length <= 0) continue;
// Unit normal to the street, for the kerb offset.
const nx = -dz / length;
const nz = dx / length;
let s = carry;
for (; s < length; s += spacing) {
if (index >= limit) break;
const t = s / length;
const lat = lat0 + (lat1 - lat0) * t;
const lng = lng0 + (lng1 - lng0) * t;
// Alternating kerbs, jittered, because a street lit by a perfect ruler
// of identical dots reads as a dashed line and not as lighting.
const side = index % 2 === 0 ? 1 : -1;
const offset = road.width * 0.55 * side * (0.8 + rand() * 0.4);
positions.push(
x0 + dx * t + nx * offset,
world.groundAt(lat, lng) + lift,
z0 + dz * t + nz * offset,
);
index++;
}
carry = Math.max(0, s - length);
}
}
if (positions.length === 0) return null;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: LAMP_COLOR,
map: lampTexture(),
size: LAMP_SIZE,
sizeAttenuation: true,
transparent: true,
opacity: 0,
// Additive, so a hundred lamps down one street saturate into the continuous
// line of light that a street at night actually is, rather than staying a
// hundred separate dots however far away they are.
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const points = new THREE.Points(geometry, material);
points.name = "streetlamps";
points.visible = false;
return { points, material };
}
/**
* The lamp's glow, drawn on a canvas rather than shipped as a file. No binary
* assets is a licensing rule and not a stylistic one; see ARCHITECTURE.md.
*/
function lampTexture(): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.22, "rgba(255,232,190,0.7)");
gradient.addColorStop(0.55, "rgba(255,190,110,0.18)");
gradient.addColorStop(1, "rgba(255,170,80,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
// ---- Helpers --------------------------------------------------------------
/** Hermite ease over a span, flat at both ends. `atmosphere.ts` has the twin. */
function smoothstep(edge0: number, edge1: number, x: number): number {
if (edge1 === edge0) return x < edge0 ? 0 : 1;
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// ---- Sanity checks --------------------------------------------------------
/**
* What this produces for San Francisco, so the numbers above can be argued with.
*
* At `metresPerUnit` 94.34 and a vertical exaggeration of 3.6, a window bay is
* 0.0424 scene units across and a storey is 0.1374 units tall — so a 260 m
* tower gets 72 floors and a 40 m lot's frontage gets ten bays, both of which
* are about right. The camera orbits between 12 and 340 units, and at 100 units
* out with a 42° field of view a bay covers roughly half a pixel, which is why
* `FRAGMENT_BODY` spends four lines on `fwidth` and would be unusable without
* them.
*
* The switch-on sequence, by solar elevation:
*
* - **+5° and above**: 0. The lamps are not drawn at all.
* - **+2°**: 0.076. The first offices, barely findable against the sky.
* - **0°, sunset**: 0.178.
* - **-2°**: 0.381.
* - **-5°, most of the way through civil twilight**: 0.814.
* - **-8° and below**: 1.0. The lights stopped changing some minutes ago;
* what changed after that was the sky behind them.
*
* Downtown's mean emission at distance is 1.8 x 0.2128 x 0.24 x 0.8 = 0.074,
* against the avenues' 0.025 — a ratio of just under 3:1, which is the whole
* picture, since the thing that makes a night skyline is not that the towers
* are taller but that they are the part of the city with all its lights still
* on. Around each of those figures the per-building variation spans 0.15x to
* 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the
* financial district does not smear into a single rectangle when you pull back.
*
* On a moonless night the buildings come out at about #4a403d against water at
* #191b21 and a sky at #2d3855: the city is the brightest thing in the frame,
* as it should be, and the sky is still visibly a sky.
*
* SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call,
* comfortably under the 24,000 ceiling. The ceiling exists for the city pack
* that arrives with a full street network in it rather than twenty-nine
* arterials, where the same spacing would produce a point cloud in the millions.
*/
+40 -6
View File
@@ -19,6 +19,7 @@
import * as THREE from "three";
import { createBlocks, createLandmarks } from "./blocks.ts";
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";
@@ -63,6 +64,12 @@ export interface SceneHandle {
stageScene: StageScene;
/** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void;
/**
* Solar elevation in degrees, for the layers that need the sun's position
* rather than the rig it implies. `LightingState` deliberately carries no
* elevation, so the number has to arrive separately.
*/
setSolarElevation(degrees: number): void;
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
@@ -78,16 +85,33 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
const stage = createStage(canvas);
const scene = new THREE.Scene();
/**
* Every camera limit is derived from how big this city's board actually is.
*
* These were constants tuned for San Francisco — `maxDistance: 340`,
* `far: 900`, a 170-unit shadow box. That silently made board size a fixed
* property of the engine rather than of a city: expanding the pack from San
* Francisco to the whole Bay Area took the board from 230 units across to
* 1003, and the camera physically could not retreat far enough to frame it.
* You got a close-up of the peninsula with everything else off-screen, and
* nothing in the types said why.
*
* A city pack now chooses its own `latScale` freely and the camera follows.
*/
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
fov: 42,
near: 0.1,
far: 900,
minDistance: 12,
maxDistance: 340,
shadowExtent: 170,
shadowFar: 520,
far: boardSpan * 3,
minDistance: Math.max(4, boardSpan * 0.02),
maxDistance: boardSpan * 1.5,
shadowExtent: boardSpan * 0.75,
shadowFar: boardSpan * 2.2,
});
kit.applyLighting(options.lighting ?? cityDaylight(pal));
@@ -95,10 +119,18 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
scene.add(createBlocks(world));
const blocks = createBlocks(world);
scene.add(blocks);
scene.add(createLandmarks(world));
scene.add(createBridges(world));
/**
* The city switching itself on after sunset. Built after `blocks` because it
* patches the material that `createBlocks` made — order is load-bearing.
*/
const nightLights: NightLights = createNightLights({ world, blocks });
scene.add(nightLights.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group);
@@ -176,6 +208,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
nightLights.dispose();
markerLayer.dispose();
kit.dispose();
scene.traverse((obj) => {
@@ -195,6 +228,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
stage,
stageScene,
setLighting: (state) => kit.applyLighting(state),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
+3 -4
View File
@@ -110,8 +110,7 @@ export function createShorePlates(world: World): THREE.Mesh {
*/
export function createTerrain(world: World): THREE.Mesh {
const pal = paletteFor(world);
const { latSteps, lngSteps, height, land } = world.lattice();
const { bounds, cellLat, cellLng } = world.city;
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const positions: number[] = [];
const colors: number[] = [];
@@ -126,8 +125,8 @@ export function createTerrain(world: World): THREE.Mesh {
const k = i * (lngSteps + 1) + j;
const existing = vertexAt[k];
if (existing !== undefined && existing >= 0) return existing;
const lat = bounds.minLat + i * cellLat;
const lng = bounds.minLng + j * cellLng;
const lat = lats[i] as number;
const lng = lngs[j] as number;
const e = height[k] ?? 0;
const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z);
+159 -24
View File
@@ -20,6 +20,8 @@ export class World {
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;
@@ -114,14 +116,26 @@ export class World {
}
/** Shortest distance to a polygon's boundary, in degrees. */
private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number {
let best = Infinity;
private distanceToEdge(lat: number, lng: number, poly: LatLng[], cap = Infinity): number {
let best = cap;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const from = poly[j];
const to = poly[i];
if (!from || !to) continue;
const [aLat, aLng] = from;
const [bLat, bLng] = to;
// Cheap rejection against the segment's own extent. The Bay Area's
// coastlines run to hundreds of vertices and this is called for every
// land point in the lattice, so skipping a segment that cannot beat the
// current best is worth the four comparisons.
if (
(lat - aLat > best && lat - bLat > best) ||
(aLat - lat > best && bLat - lat > best) ||
(lng - aLng > best && lng - bLng > best) ||
(aLng - lng > best && bLng - lng > best)
) {
continue;
}
const dLat = bLat - aLat;
const dLng = bLng - aLng;
const lenSq = dLat * dLat + dLng * dLng;
@@ -173,14 +187,19 @@ export class World {
/** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
private coastalFalloff(lat: number, lng: number): number {
const limit = this.city.coastFalloff;
let d = Infinity;
for (const poly of this.city.landmasses) {
if (this.pointInPolygon(lat, lng, poly)) {
d = Math.min(d, this.distanceToEdge(lat, lng, poly));
}
if (!this.pointInPolygon(lat, lng, poly)) continue;
// Seed the search with the cap: the result saturates at `coastFalloff`,
// so any edge farther than that cannot change the answer, and seeding
// `best` lets the per-segment rejection above discard almost everything
// for a point well inland.
const dist = this.distanceToEdge(lat, lng, poly, limit);
if (dist < d) d = dist;
}
if (!Number.isFinite(d)) return 0;
const t = Math.min(1, d / this.city.coastFalloff);
const t = Math.min(1, d / limit);
return t * t * (3 - 2 * t);
}
@@ -193,17 +212,39 @@ export class World {
* camera target wants it again. Computed once, read back bilinearly.
*/
private buildField(): { height: Float32Array; land: Uint8Array } {
if (this.field && this.fieldLand) return { height: this.field, land: this.fieldLand };
if (this.field && this.fieldLand && this.lats && this.lngs) {
return { height: this.field, land: this.fieldLand };
}
const { bounds, cellLat, cellLng } = this.city;
this.latSteps = Math.ceil((bounds.maxLat - bounds.minLat) / cellLat);
this.lngSteps = Math.ceil((bounds.maxLng - bounds.minLng) / cellLng);
const coarse = Math.max(1, this.city.coarseFactor ?? 1);
const regions = this.city.focusRegions ?? [];
// Rectilinear but NOT uniform: fine spacing across any band that a focus
// 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]),
);
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 = bounds.minLat + i * cellLat;
const lat = this.lats[i] as number;
for (let j = 0; j <= this.lngSteps; j++) {
const lng = bounds.minLng + j * cellLng;
const lng = this.lngs[j] as number;
const k = i * w + j;
const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
@@ -215,24 +256,50 @@ export class World {
return { height, land };
}
/** Lattice dimensions, for the terrain mesh builder. */
lattice(): { latSteps: number; lngSteps: number; height: Float32Array; land: Uint8Array } {
/**
* The lattice, for the terrain mesh builder.
*
* `lats`/`lngs` are the coordinate of every row and column, because the
* 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, height, land };
return {
latSteps: this.latSteps,
lngSteps: this.lngSteps,
lats: this.lats as Float64Array,
lngs: this.lngs as Float64Array,
height,
land,
};
}
/** Elevation in metres, bilinearly sampled from the cached lattice. */
elevationSampled(lat: number, lng: number): number {
const { height } = this.buildField();
const { bounds, cellLat, cellLng } = this.city;
const lats = this.lats as Float64Array;
const lngs = this.lngs as Float64Array;
const w = this.lngSteps + 1;
const fi = (lat - bounds.minLat) / cellLat;
const fj = (lng - bounds.minLng) / cellLng;
if (fi < 0 || fj < 0 || fi >= this.latSteps || fj >= this.lngSteps) return 0;
const i = Math.floor(fi);
const j = Math.floor(fj);
const ti = fi - i;
const tj = fj - j;
const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng);
if (i < 0 || j < 0) return 0;
const lat0 = lats[i] as number;
const lat1 = lats[i + 1] as number;
const lng0 = lngs[j] as number;
const lng1 = lngs[j + 1] as number;
const ti = lat1 > lat0 ? (lat - lat0) / (lat1 - lat0) : 0;
const tj = lng1 > lng0 ? (lng - lng0) / (lng1 - lng0) : 0;
const a = height[i * w + j] ?? 0;
const b = height[i * w + j + 1] ?? 0;
const c = height[(i + 1) * w + j] ?? 0;
@@ -246,11 +313,79 @@ export class World {
}
}
// ---- Variable-resolution lattice ------------------------------------------
/**
* The coordinates of every row (or column) of the heightfield: fine spacing
* across the bands the focus regions occupy, coarse in between.
*
* This is what makes a region the size of Southern California renderable at
* all. San Francisco is 0.20 x 0.36 degrees and a uniform 45 m lattice over it
* is 337k points — fine. LA plus Orange County plus Riverside is about
* fourteen times that area, and the same uniform lattice is 2.9M points; the
* Bay Area extended to San Jose is 3.7M and roughly eleven seconds of build.
* Neither is a thing anyone waits for.
*
* The refinement is per-axis rather than per-rectangle, so a focus region
* refines its whole row *and* its whole column — a plus, not a box. That over-
* samples the corners where two regions' bands cross, and it is deliberate: it
* keeps the lattice rectilinear, which keeps the mesh builder a double loop and
* bilinear sampling a pair of binary searches. A true quadtree would sample
* less and cost far more everywhere else.
*/
function buildAxis(
min: number,
max: number,
fine: number,
coarse: number,
bands: [number, number][],
): Float64Array {
const out: number[] = [min];
let x = min;
// A guard band of one coarse cell either side, so the transition from coarse
// to fine happens outside the region rather than exactly on its edge, where
// it would show as a crease in the terrain.
const inFine = (v: number) => bands.some(([a, b]) => v >= a - coarse && v <= b + coarse);
while (x < max) {
x += inFine(x) ? fine : coarse;
out.push(Math.min(x, max));
}
// A degenerate final cell (the clamp above landing on `max` twice) would make
// a zero-width row that the interpolator would divide by.
if (out.length > 1 && out[out.length - 1] === out[out.length - 2]) out.pop();
return Float64Array.from(out);
}
/** Index of the cell containing `v`, or -1 if outside. Binary search. */
function cellIndex(axis: Float64Array, v: number): number {
const last = axis.length - 1;
if (v < (axis[0] as number) || v >= (axis[last] as number)) return -1;
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if ((axis[mid] as number) <= v) lo = mid;
else hi = mid;
}
return lo;
}
// ---- Deterministic noise and randomness -----------------------------------
/**
* Integer hash, not the `Math.sin(...) * 43758` trick this used to be.
*
* `valueNoise` calls this four times and `fbm` runs four octaves, so every
* elevation sample was sixteen `Math.sin` calls. Over the Bay Area's lattice
* that is twenty-six million of them, and it was most of an eleven-second
* terrain build. The inputs are already integers here — `valueNoise` floors
* them — so an imul-based mix is both faster and better distributed.
*/
function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return s - Math.floor(s);
let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
function valueNoise(x: number, y: number): number {
+183 -100
View File
@@ -1,73 +1,55 @@
/**
* The standalone demo: San Francisco under a real sun, and one office you can
* step into.
* The demo: two cities under a real sun and moon, and one office you can step
* into.
*
* Deliberately ships **no company data**. Markers are demonstrated using the
* city's own landmarks — buildings, not businesses — because company positions
* are geocoded and company pipeline status is private, and neither belongs in
* this repo. Real markers arrive at runtime from an adapter; see `src/adapters/`
* and ARCHITECTURE.md §3.
* It ships **no real company data**. The markers are fabricated — see
* `src/adapters/sample.ts`, which says so loudly — because real positions are
* geocoded and real pipeline status is private, and neither belongs in this
* repo. When a Tera API is present the same markers arrive from it instead, and
* the UI says which of the two it is showing.
*
* It also makes no network calls. The sun is computed locally, the traffic is
* simulated and the office is a data file, so a clone of this repo runs.
* It also runs with no server at all: the sun and moon are computed locally,
* the traffic is simulated, the office is a data file. Clone it and it works.
*/
import { createAtmosphere, observe, PACIFIC_MARINE_LAYER } from "./engine/atmosphere.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import { SimulatedFlights } from "./engine/flights.ts";
import { daylightPhase } from "./engine/solar.ts";
import { SimulatedFlights, type SimRoute } from "./engine/flights.ts";
import { createScene } from "./engine/scene.ts";
import type { Marker, MarkerPalette, View } from "./engine/types.ts";
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts";
import { createTeraClient } from "./adapters/http.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts";
import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
/**
* Bay Area traffic, roughly where it actually is: SFO sits south of frame and
* its arrivals run down the peninsula, Oakland is east across the bay, and the
* coastal departures turn out over the Pacific.
*/
const ROUTES: SimRoute[] = [
{ callsign: "UAL 1", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
{ callsign: "ASA 22", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
{ callsign: "SWA 118", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
{ callsign: "DAL 407", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
{ callsign: "UAL 88", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
{ callsign: "N512SP", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
{ callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
const CITIES: { id: string; label: string; city: City }[] = [
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
{ id: "socal", label: "SoCal", city: SOCAL },
];
const MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad };
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
const city = createScene(canvas, {
city: SAN_FRANCISCO,
markerPalette: MARKER_PALETTE,
flights: new SimulatedFlights(ROUTES),
onMarkerPick: (marker) => showDetail(marker?.label ?? null),
});
const tera = createTeraClient();
// ---- The sun --------------------------------------------------------------
let city: SceneHandle | null = null;
let cityId = "sf";
let office: OfficeScene | null = null;
let inside = false;
let markers: Marker[] = SAMPLE_MARKERS;
let palette: MarkerPalette = SAMPLE_PALETTE;
let liveData = false;
let canEnterOffice = false;
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
// ---- Time -----------------------------------------------------------------
/**
* Real solar position for San Francisco, right now, recomputed every minute.
*
* No network and no timezone database — `solar.ts` is arithmetic — so this
* keeps working on a laptop in a field. The marine layer is switched on because
* the fog is the single most recognisable atmospheric fact about this city.
*/
const atmosphere = createAtmosphere({
lng: SAN_FRANCISCO.center.lng,
metresPerUnit: city.world.metresPerUnit,
marineLayer: PACIFIC_MARINE_LAYER,
});
/**
* `null` follows the wall clock. A number is an hour-of-day override from the
* scrubber, which exists because the honest answer at 2 a.m. is a black
* rectangle — correct, and impossible to look at. Being able to drag the sun is
* also the only practical way to eyeball whether the solar maths is right.
* `null` follows the wall clock. The scrubber exists because the honest answer
* at 2 a.m. is a very dark city — correct, and not what you want to be looking
* at while judging whether the sun is in the right place.
*/
let hourOverride: number | null = null;
@@ -80,55 +62,80 @@ function currentInstant(): Date {
}
function updateSun() {
const env = observe(SAN_FRANCISCO.center.lat, SAN_FRANCISCO.center.lng, currentInstant());
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
if (!city || !atmosphere) return;
const env = observe(active.center.lat, active.center.lng, currentInstant());
city.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
const clock = document.querySelector<HTMLElement>("#clock");
if (!clock) return;
const el = env.sun.elevation;
const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const phase = daylightPhase(el);
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${phase}${hourOverride === null ? "" : " (held)"}`;
const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
}
const scrubber = document.querySelector<HTMLInputElement>("#hour");
scrubber?.addEventListener("input", () => {
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#now")?.addEventListener("click", () => {
hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
updateSun();
});
updateSun();
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
// ---- Demo markers ---------------------------------------------------------
const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks
.filter((l) => l.label)
.map((l) => ({
id: l.name,
lat: l.lat,
lng: l.lng,
label: l.name,
colorKey: "landmark",
located: true,
}));
city.setMarkers(demoMarkers);
// ---- The office -----------------------------------------------------------
// ---- Cities ---------------------------------------------------------------
/**
* Built on first entry and then kept, for the same reason the city is paused
* rather than disposed on the way in: rebuilding either scene costs far more
* than holding it.
* Switching city tears the old one down completely.
*
* Unlike the city↔office move — where the city is paused and kept, because you
* are coming straight back — nobody flips between metros often enough to
* justify holding two heightfields and 140k building instances at once.
*/
let office: OfficeScene | null = null;
let inside = false;
function mountCity(id: string) {
const entry = CITIES.find((c) => c.id === id);
if (!entry || !canvas) return;
office?.dispose();
office = null;
inside = false;
city?.dispose();
cityId = id;
city = createScene(canvas, {
city: entry.city,
markerPalette: palette,
flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? `${m.blurb}` : ""}` : null),
});
// Fog distances are scene units, so they have to follow the board — 210/460
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
// out on the whole-board view: a fog starting nearer than that is behind the
// viewer's own shoulder, and at night, when the fog colour is nearly black
// rather than bright haze, it turns the entire map off.
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
atmosphere = createAtmosphere({
lng: entry.city.center.lng,
metresPerUnit: city.world.metresPerUnit,
clearFog: { near: span * 1.15, far: span * 2.8 },
// The floor on how far you can see, and it has to know how big the board
// is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
// completely wrong here: this board is ninety-four kilometres across, so
// real visibility correctly hides three quarters of it and the night view
// renders as a black rectangle. A map is looked at from outside the
// atmosphere it is depicting.
minVisibilityM: span * city.world.metresPerUnit * 1.6,
// The marine layer is a fact about the eastern Pacific at this latitude,
// not a decoration. LA gets its own weather, not San Francisco's fog.
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
});
city.setMarkers(id === "sf" ? markers : []);
city.onChapterChange(() => renderLegend());
updateSun();
renderLegend();
}
// ---- Office ---------------------------------------------------------------
function enterOffice() {
if (!city || !canEnterOffice) return;
if (!office) {
office = createOfficeScene(LUMBRIDGE_HQ, {
dom: city.stage.renderer.domElement,
@@ -143,6 +150,7 @@ function enterOffice() {
}
function leaveOffice() {
if (!city) return;
city.stage.setScene(city.stageScene);
inside = false;
showDetail(null);
@@ -156,6 +164,8 @@ const blurb = document.querySelector<HTMLElement>("#blurb");
const title = document.querySelector<HTMLElement>("#title");
const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -164,12 +174,25 @@ function showDetail(text: string | null) {
card.textContent = text ?? "";
}
/**
* One legend for both places. A city chapter and an office viewpoint are both
* `View`s, which is the whole reason that type was extracted.
*/
function renderCityPicker() {
if (!cityNav) return;
cityNav.replaceChildren();
for (const c of CITIES) {
const b = document.createElement("button");
b.className = c.id === cityId && !inside ? "city active" : "city";
b.textContent = c.label;
b.addEventListener("click", () => {
if (inside) leaveOffice();
if (c.id !== cityId) mountCity(c.id);
});
cityNav.append(b);
}
}
/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
function renderLegend() {
if (!nav) return;
renderCityPicker();
if (!nav || !city) return;
const views: View[] = inside && office ? office.views : city.chapters;
const activeId = inside && office ? office.current() : city.current();
@@ -181,7 +204,7 @@ function renderLegend() {
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
button.addEventListener("click", () => {
if (inside && office) office.flyTo(view.id);
else city.flyTo(view.id);
else city?.flyTo(view.id);
});
nav.append(button);
});
@@ -191,11 +214,71 @@ function renderLegend() {
blurb.textContent = active?.description ?? "";
blurb.hidden = !active?.description;
}
if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : SAN_FRANCISCO.name;
if (subtitle) subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
if (enterButton) enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : cityLabel;
if (subtitle) {
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
}
if (enterButton) {
if (inside) enterButton.textContent = "← Back to the city";
else if (canEnterOffice) enterButton.textContent = "Enter the office →";
else enterButton.textContent = "Sign in to enter the office →";
}
if (source) {
source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies";
source.className = liveData ? "source live" : "source";
}
}
enterButton?.addEventListener("click", () => (inside ? leaveOffice() : enterOffice()));
city.onChapterChange(() => renderLegend());
renderLegend();
enterButton?.addEventListener("click", () => {
if (inside) leaveOffice();
else if (canEnterOffice) enterOffice();
else window.location.href = "/login.html";
});
const scrubber = document.querySelector<HTMLInputElement>("#hour");
scrubber?.addEventListener("input", () => {
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#now")?.addEventListener("click", () => {
hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
updateSun();
});
// ---- Boot -----------------------------------------------------------------
/**
* Markers are awaited before the scene is built, because `markerPalette` is
* fixed at construction and the sample palette's keys are not the API's.
* Everything else about the API is optional: no server means sample data and a
* label saying so.
*/
async function boot() {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
try {
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
if (res.ok) {
const s = (await res.json()) as { authenticated: boolean; passwordLogin: boolean };
canEnterOffice = s.authenticated || !s.passwordLogin;
} else {
canEnterOffice = true;
}
} catch {
// No server means nothing to sign in to, so the office is open. That is the
// self-host posture: auth is something a deployment adds, not removes.
canEnterOffice = true;
}
mountCity("sf");
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
}
void boot();