Lumbridge Simulate Engine — the city, and the licence it can actually ship under

LSE is the third of the three, beside lumbridge-compute and lumbridge-bench: a
3D engine for walkable places. This first commit is the outside of the world —
San Francisco — plus the seams the inside will attach to.

The engine renders a City and a list of Markers and knows nothing else. It does
not know markers are usually companies and it will never learn that "rejected"
is red; that mapping lives in an adapter. Which is what lets one renderer serve
a private map, a public one, and a self-hoster with no Lumbridge account, none
of them a fork of the others.

Three things were designed around the licence rather than discovered after it,
because each one is a promise Apache 2.0 makes that is easy to break by
accident. No trademarks in the repo — logos are fetched at runtime, and
public/logos/ is gitignored. No OpenStreetMap-derived coordinates, which is why
every coastline in cities/sf.ts was traced by hand: Nominatim output is ODbL,
share-alike, and would attach to the whole pack. And no FlightRadar24 client —
their terms forbid scraping and redistribution, so flights are an interface
with a simulator and open community ADS-B behind it.

The privacy constraint and the licence constraint turned out to want the same
thing. Geocoded company positions and pipeline status both stay behind Workie's
API; the open repo holds the city and the renderer. The tempting shortcut —
commit an sf-companies.json — breaks both at once.

Ported out of Workie, where a 3D city engine had no business living. Workie's
/live is deleted rather than deprecated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-04 21:49:08 -07:00
commit 67f8df8d53
20 changed files with 4789 additions and 0 deletions
+1066
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
/**
* The built city, plus the handful of landmarks placed by hand.
*
* Two things make this read as a city rather than as noise:
*
* - **Buildings step along a block lattice** in each district's own grid
* bearing. An earlier version rejection-sampled uniformly inside each
* district and it looked like rubble, because a city is not a Poisson
* process. In San Francisco the lattice also reproduces the 46° between
* the grid north of Market and the grid south of it, and Market Street
* falls out as a seam rather than having to be drawn.
* - **Buildings stand on the terrain.** Every base is sampled from
* `world.groundAt`, so Nob Hill's low-rises tower over taller blocks in the
* flats below — which is true of that city, and which a flat map gets
* exactly backwards.
*
* Everything is instanced: one draw call for the whole city.
*/
import * as THREE from "three";
import type { District } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** Lot size in scene units, and how many lots sit between cross-streets. */
const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
const PALETTES = {
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;
interface Box {
x: number;
z: number;
y: number;
w: number;
d: number;
h: number;
rot: number;
color: THREE.Color;
}
function polygonBounds(poly: [number, number][]) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const [lat, lng] of poly) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
return { minLat, maxLat, minLng, maxLng };
}
export function createBlocks(world: World): THREE.InstancedMesh {
const boxes: Box[] = [];
let seedBase = 1337;
for (const district of world.city.districts) {
const rand = seededRandom(seedBase);
seedBase += 7919;
const palette = PALETTES[district.palette];
const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88;
// The district's extent in scene space, padded so the rotated lattice
// still covers the corners once it is turned.
const b = polygonBounds(district.polygon);
const corners = [
world.project(b.minLat, b.minLng),
world.project(b.minLat, b.maxLng),
world.project(b.maxLat, b.minLng),
world.project(b.maxLat, b.maxLng),
];
const xs = corners.map((c) => c[0]);
const zs = corners.map((c) => c[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const steps = Math.ceil(reach / LOT);
for (let iu = -steps; iu <= steps; iu++) {
if (((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
for (let iv = -steps; iv <= steps; iv++) {
if (((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
const x = cx + u * cos - v * sin;
const z = cz + u * sin + v * cos;
const [lat, lng] = world.unproject(x, z);
if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
if (!world.isLand(lat, lng)) continue;
if (world.pointInAny(lat, lng, world.city.parks)) continue;
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
// Cubed, so tall buildings stay rare and the skyline keeps a
// silhouette instead of turning into a plateau.
const roll = rand();
const isTower = rand() < district.towerChance;
const t = isTower ? 0.55 + roll * 0.45 : roll ** 3;
const heightM = district.minHeight + t * (district.maxHeight - district.minHeight);
// Towers take several lots. A 260 m tower on one 40 m lot is a 25:1
// needle, and downtown came out looking like a bed of nails; real
// towers assemble their sites, and Salesforce Tower is about 5:1.
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
boxes.push({
x,
z,
y: world.groundAt(lat, lng),
w: LOT * fill,
d: LOT * fill * (0.85 + rand() * 0.3),
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
});
}
}
}
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
mesh.castShadow = true;
mesh.receiveShadow = true;
const matrix = new THREE.Matrix4();
const quat = new THREE.Quaternion();
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
boxes.forEach((b, i) => {
pos.set(b.x, b.y, b.z);
quat.setFromAxisAngle(up, b.rot);
scl.set(b.w, b.h, b.d);
matrix.compose(pos, quat, scl);
mesh.setMatrixAt(i, matrix);
mesh.setColorAt(i, b.color);
});
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return mesh;
}
/**
* The named buildings. Separate meshes because the eye goes looking for these
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
* Hill, the red tripod on the ridge — and a box would not do.
*/
export function createLandmarks(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "landmarks";
for (const lm of world.city.landmarks) {
const [x, z] = world.project(lm.lat, lm.lng);
const base = world.groundAt(lm.lat, lm.lng);
const h = world.metres(lm.height);
const w = lm.footprint * world.lngScale * 2;
let geo: THREE.BufferGeometry;
switch (lm.shape) {
case "pyramid":
geo = new THREE.ConeGeometry(w * 0.72, h, 4);
geo.translate(0, h / 2, 0);
geo.rotateY(Math.PI / 4);
break;
case "cylinder":
geo = new THREE.CylinderGeometry(w * 0.6, w * 0.68, h, 20);
geo.translate(0, h / 2, 0);
break;
case "tower":
geo = new THREE.CylinderGeometry(w * 0.42, w * 0.62, h, 4);
geo.rotateY(Math.PI / 4);
geo.translate(0, h / 2, 0);
break;
default:
geo = new THREE.BoxGeometry(w, h, w);
geo.translate(0, h / 2, 0);
}
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: lm.color ?? 0xaebac6 }),
);
mesh.position.set(x, base, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.landmark = lm;
group.add(mesh);
}
return group;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Aircraft over the city.
*
* The engine takes a `FlightSource` rather than talking to any particular
* service, because the obvious one cannot ship here. FlightRadar24's terms
* forbid scraping and forbid redistributing their 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. Commercial sources are
* adapters in a private deployment; this file holds what we can actually give
* away. See ARCHITECTURE.md §4.
*
* `SimulatedFlights` is the default and is genuinely enough for the map — what
* a city view wants is convincing motion in the right corridors, not a
* spotter's log.
*/
import * as THREE from "three";
import type { Aircraft, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
export interface SimRoute {
callsign: string;
from: [number, number];
to: [number, number];
/** Metres at the start and end of the leg. */
fromAlt: number;
toAlt: number;
/** Seconds for a full traversal. */
duration: number;
}
/**
* Traffic that behaves like the real thing without being it: aircraft move
* along fixed legs at fixed speeds, looping, with each one offset in phase so
* the sky is never empty and never synchronised.
*/
export class SimulatedFlights implements FlightSource {
readonly interval = 1;
private readonly routes: SimRoute[];
private readonly phase: number[];
private t = 0;
private last = 0;
constructor(routes: SimRoute[], seed = 4711) {
this.routes = routes;
const rand = seededRandom(seed);
this.phase = routes.map(() => rand());
this.last = nowSeconds();
}
poll(): Aircraft[] {
const now = nowSeconds();
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 };
});
}
}
function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}
/**
* Community ADS-B, for when real traffic is wanted.
*
* `adsb.lol` and `airplanes.live` both serve open, key-free feeds of
* volunteer-fed ADS-B and are the sources this project can point at without a
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with.
*/
export class AdsbFlights implements FlightSource {
readonly interval = 8;
constructor(
private readonly endpoint: string,
private readonly radiusNm = 25,
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
) {}
async poll(): Promise<Aircraft[]> {
const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`;
try {
const res = await fetch(url);
if (!res.ok) return [];
const body = (await res.json()) as { ac?: RawAircraft[] };
return (body.ac ?? [])
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
.map((a) => ({
id: a.hex ?? `${a.flight ?? "?"}`,
callsign: a.flight?.trim(),
lat: a.lat as number,
lng: a.lon as number,
// Feed reports feet; the scene works in metres.
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
heading: typeof a.track === "number" ? a.track : 0,
}));
} catch {
// A dead feed must not take the render loop with it.
return [];
}
}
}
interface RawAircraft {
hex?: string;
flight?: string;
lat?: number;
lon?: number;
alt_baro?: number;
track?: number;
}
// ---- Rendering ------------------------------------------------------------
export interface FlightLayer {
group: THREE.Group;
update(aircraft: Aircraft[]): 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.
*/
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>();
function update(aircraft: Aircraft[]) {
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;
}
for (const [id, mesh] of meshes) {
if (seen.has(id)) continue;
group.remove(mesh);
meshes.delete(id);
}
}
return {
group,
update,
dispose() {
geo.dispose();
material.dispose();
meshes.clear();
group.clear();
},
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Pins on the map.
*
* This module is deliberately ignorant. It renders `Marker[]` and looks colours
* up by `colorKey` in a palette the caller supplies. It does not know that
* markers are usually companies, and it will never learn that "rejected" is
* red — that mapping belongs to the adapter in the consuming app, which is what
* lets one engine serve a private career map and a public sector map without
* either being a fork. See ARCHITECTURE.md §3.3.
*/
import * as THREE from "three";
import type { Marker, MarkerPalette } from "./types.ts";
import type { World } from "./world.ts";
const FALLBACK_COLOR = 0x9aa4ad;
export interface MarkerLayer {
group: THREE.Group;
/** Raycast targets, for hover and click. */
pickables: THREE.Object3D[];
/** Scene-space head position per marker id, for the HTML label layer. */
anchors: Map<string, THREE.Vector3>;
setMarkers(markers: Marker[]): void;
dispose(): void;
}
/**
* How high above the roofline a pin floats, in scene units. Enough to clear
* a mid-rise; towers will still occlude, which is honest — a pin behind the
* skyline should look like it is behind the skyline.
*/
const PIN_LIFT = 1.6;
const PIN_HEIGHT = 1.1;
export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerLayer {
const group = new THREE.Group();
group.name = "markers";
const pickables: THREE.Object3D[] = [];
const anchors = new Map<string, THREE.Vector3>();
// One shared geometry per shape; colour varies per instance material, which
// is cheap enough at the scale markers live at (hundreds, not tens of
// thousands — that is what `blocks` is for).
const stemGeo = new THREE.CylinderGeometry(0.045, 0.045, PIN_HEIGHT, 6);
stemGeo.translate(0, PIN_HEIGHT / 2, 0);
const headGeo = new THREE.SphereGeometry(0.16, 12, 10);
const ghostGeo = new THREE.OctahedronGeometry(0.15);
const materials = new Map<string, THREE.Material>();
const materialFor = (key: string, located: boolean): THREE.Material => {
const id = `${key}:${located ? "solid" : "ghost"}`;
const existing = materials.get(id);
if (existing) return existing;
const color = palette[key] ?? FALLBACK_COLOR;
const mat = located
? new THREE.MeshLambertMaterial({ color, emissive: color, emissiveIntensity: 0.28 })
: new THREE.MeshLambertMaterial({ color, transparent: true, opacity: 0.42 });
materials.set(id, mat);
return mat;
};
function clear() {
for (const child of [...group.children]) group.remove(child);
pickables.length = 0;
anchors.clear();
}
function setMarkers(markers: Marker[]) {
clear();
for (const m of markers) {
const located = m.located !== false;
const [x, z] = world.project(m.lat, m.lng);
const base = world.groundAt(m.lat, m.lng);
const pin = new THREE.Group();
pin.position.set(x, base + PIN_LIFT, z);
const stem = new THREE.Mesh(stemGeo, materialFor(m.colorKey, located));
pin.add(stem);
// Unplaced markers get a different silhouette as well as a different
// opacity. Colour alone is not enough of a tell, and a map whose premise
// is that it is real must not quietly invent addresses.
const head = new THREE.Mesh(
located ? headGeo : ghostGeo,
materialFor(m.colorKey, located),
);
head.position.y = PIN_HEIGHT;
head.userData.marker = m;
pin.add(head);
pickables.push(head);
anchors.set(m.id, new THREE.Vector3(x, base + PIN_LIFT + PIN_HEIGHT, z));
group.add(pin);
}
}
return {
group,
pickables,
anchors,
setMarkers,
dispose() {
clear();
stemGeo.dispose();
headGeo.dispose();
ghostGeo.dispose();
for (const m of materials.values()) m.dispose();
materials.clear();
},
};
}
+267
View File
@@ -0,0 +1,267 @@
/**
* The scene: lights, sky, layers, camera flights, render loop.
*
* `createScene` owns a canvas and a `City` and nothing else. It knows nothing
* about React, about any API, or about what the markers mean — the caller hands
* it data and gets back a small imperative handle. That boundary is what lets
* one renderer serve a private map coloured by pipeline state and a public one
* coloured by sector without either being a fork.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts";
import { World } from "./world.ts";
export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
flights?: FlightSource;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
}
export interface SceneHandle {
world: World;
chapters: Chapter[];
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
setMarkers(markers: Marker[]): void;
dispose(): void;
}
export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle {
const { city } = options;
const world = new World(city);
const pal = paletteFor(world);
const scene = new THREE.Scene();
scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon);
scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460);
const camera = new THREE.PerspectiveCamera(
42,
canvas.clientWidth / Math.max(1, canvas.clientHeight),
0.1,
900,
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = 12;
controls.maxDistance = 340;
// Late-afternoon sun from the west, which throws the hills' shadows east
// across the flats.
const sun = new THREE.DirectionalLight(0xfff3e0, 2.1);
sun.position.set(-150, 170, 70);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 520;
const extent = 170;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = -0.0012;
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05));
scene.add(new THREE.AmbientLight(0xffffff, 0.32));
scene.add(createWater(world));
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
scene.add(createBlocks(world));
scene.add(createLandmarks(world));
scene.add(createBridges(world));
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
if (options.flights) {
flightLayer = createFlightLayer(world);
scene.add(flightLayer.group);
}
// ---- Camera flights -----------------------------------------------------
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
const first = city.chapters[0];
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
const desiredTarget = new THREE.Vector3();
const desiredPosition = new THREE.Vector3();
const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() };
let flying = false;
let flightT = 0;
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
function chapterPose(ch: Chapter) {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
return {
target: new THREE.Vector3(x, groundY, z),
pos: new THREE.Vector3(
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
groundY + ch.focus.height,
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
),
};
}
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
const pose = chapterPose(ch);
flightFrom.pos.copy(camera.position);
flightFrom.target.copy(controls.target);
desiredPosition.copy(pose.pos);
desiredTarget.copy(pose.target);
flightT = 0;
flying = true;
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
}
}
{
const pose = chapterPose(first);
camera.position.copy(pose.pos);
controls.target.copy(pose.target);
controls.update();
}
// ---- Picking ------------------------------------------------------------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered: Marker | null = null;
function onPointerMove(event: PointerEvent) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0];
const marker = (hit?.object.userData.marker as Marker | undefined) ?? null;
if (marker !== hovered) {
hovered = marker;
canvas.style.cursor = marker ? "pointer" : "";
options.onMarkerPick?.(marker);
}
}
canvas.addEventListener("pointermove", onPointerMove);
// ---- Loop ---------------------------------------------------------------
const clock = new THREE.Clock();
let raf = 0;
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
}
function tick() {
raf = requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.05);
resize();
if (flying) {
flightT = Math.min(1, flightT + dt * 0.65);
// easeInOutCubic — a flight that starts and lands gently
const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
camera.position.lerpVectors(flightFrom.pos, desiredPosition, e);
controls.target.lerpVectors(flightFrom.target, desiredTarget, e);
if (flightT >= 1) flying = false;
}
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
}
}
controls.update();
renderer.render(scene, camera);
}
tick();
const onWindowResize = () => resize();
window.addEventListener("resize", onWindowResize);
return {
world,
chapters: city.chapters,
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
chapterListeners.push(fn);
},
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onWindowResize);
canvas.removeEventListener("pointermove", onPointerMove);
options.flights?.dispose?.();
flightLayer?.dispose();
markerLayer.dispose();
controls.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else if (mat) (mat as THREE.Material).dispose();
});
renderer.dispose();
},
};
}
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = 2;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 2, 256);
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.LinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Bridges and roads — the lines that tie the landmasses together and give the
* grid something to hang off.
*
* Roads follow the terrain: each path is resampled far more finely than it is
* written in the city pack, and every sample takes its height from the ground,
* so a street climbs out of the flats instead of burrowing through the hill.
*/
import * as THREE from "three";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
const out: THREE.Vector3[] = [];
for (let i = 0; i < path.length - 1; i++) {
const from = path[i];
const to = path[i + 1];
if (!from || !to) continue;
const [lat0, lng0] = from;
const [lat1, lng1] = to;
const steps = i === path.length - 2 ? samplesPerLeg : samplesPerLeg - 1;
for (let s = 0; s <= steps; s++) {
const t = s / samplesPerLeg;
const lat = lat0 + (lat1 - lat0) * t;
const lng = lng0 + (lng1 - lng0) * t;
const [x, z] = world.project(lat, lng);
out.push(new THREE.Vector3(x, world.groundAt(lat, lng) + lift, z));
}
}
return out;
}
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
const curve = new THREE.CatmullRomCurve3(points);
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
mesh.receiveShadow = true;
return mesh;
}
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";
for (const road of world.city.roads) {
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
group.add(ribbon(drapePath(world, road.path), road.width, color));
}
return group;
}
/**
* A suspension bridge: deck, towers, and a main cable sagging between them.
*
* The cable is the detail worth the code. Two orange towers with a straight
* line between them read as a trestle; the catenary is what makes the shape at
* the mouth of the bay unmistakably the Golden Gate.
*/
export function createBridge(world: World, bridge: Bridge): THREE.Group {
const group = new THREE.Group();
group.name = bridge.name;
const deckY = world.metres(bridge.deckHeight);
const towerY = world.metres(bridge.towerHeight);
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
const deckPoints = bridge.path.map(([lat, lng]) => {
const [x, z] = world.project(lat, lng);
return new THREE.Vector3(x, deckY, z);
});
const deck = ribbon(deckPoints, 0.5, bridge.color);
deck.castShadow = true;
group.add(deck);
const towerTops: THREE.Vector3[] = [];
for (const [lat, lng] of bridge.towers) {
const [x, z] = world.project(lat, lng);
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
geo.translate(0, towerY / 2, 0);
const tower = new THREE.Mesh(geo, material());
tower.position.set(x, 0, z);
tower.castShadow = true;
group.add(tower);
// Cross-braces, which is most of what you see of a tower at distance.
for (const frac of [0.55, 0.82]) {
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
brace.position.set(x, towerY * frac, z);
group.add(brace);
}
towerTops.push(new THREE.Vector3(x, towerY, z));
}
const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]];
for (let i = 0; i < anchors.length - 1; i++) {
const a = anchors[i];
const b = anchors[i + 1];
if (!a || !b) continue;
const isMainSpan = i > 0 && i < anchors.length - 2;
const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42);
const pts: THREE.Vector3[] = [];
for (let s = 0; s <= 18; s++) {
const t = s / 18;
const p = a.clone().lerp(b, t);
p.y -= Math.sin(t * Math.PI) * sag;
pts.push(p);
}
group.add(
new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
material(),
),
);
// Vertical hangers down to the deck.
for (let s = 2; s < 18; s += 2) {
const t = s / 18;
const p = a.clone().lerp(b, t);
const top = p.y - Math.sin(t * Math.PI) * sag;
if (top <= deckY + 0.2) continue;
const h = top - deckY;
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
geo.translate(0, h / 2, 0);
const hanger = new THREE.Mesh(geo, material());
hanger.position.set(p.x, deckY, p.z);
group.add(hanger);
}
}
return group;
}
export function createBridges(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "bridges";
for (const b of world.city.bridges) group.add(createBridge(world, b));
return group;
}
+200
View File
@@ -0,0 +1,200 @@
/**
* The ground: water, coastline, and relief.
*
* Drawn as two meshes rather than one, deliberately:
*
* 1. A **shore plate** per landmass — the smooth polygon from the city pack,
* triangulated flat at y=0. This is what gives the coastline a crisp edge
* at any zoom.
* 2. A **terrain grid** on top, clipped to land and displaced. Its edge is
* necessarily stair-stepped at cell size, which is why the world's coastal
* falloff ramps every height to zero near the water: the grid's blocky rim
* ends up flat, at y≈0, exactly where the smooth plate already is and in
* the same colour. The steps vanish.
*
* One mesh would have to choose between a crisp coastline and cheap relief.
*/
import * as THREE from "three";
import type { ScenePalette } from "./types.ts";
import type { World } from "./world.ts";
export const DEFAULT_PALETTE: ScenePalette = {
skyTop: 0x8fb8d8,
skyHorizon: 0xd9e6ee,
sea: 0x4a7a99,
lake: 0x527f9c,
shore: 0xa8a495,
sand: 0xc4b79b,
flats: 0x9d9c93,
upland: 0x8f9084,
park: 0x6f8a5c,
parkHigh: 0x5d7a4c,
};
export function paletteFor(world: World): ScenePalette {
return { ...DEFAULT_PALETTE, ...(world.city.palette ?? {}) };
}
/**
* Ground colour is about land *use*, not altitude.
*
* An earlier version ramped green with elevation, which turned every hill into
* a meadow — and in San Francisco the hills are the most thoroughly built-on
* part of the city. Nob Hill, Pacific Heights, Bernal and Potrero are houses to
* the summit; the genuinely green high ground is Twin Peaks, Sutro, Davidson
* and McLaren, all of which are parks and get their green from being in
* `city.parks`. Everywhere else stays city-coloured however high it goes, and
* the buildings do the rest of the talking.
*/
function groundColor(
world: World,
pal: ScenePalette,
scratch: THREE.Color,
lat: number,
lng: number,
elevation: number,
): THREE.Color {
if (world.pointInAny(lat, lng, world.city.parks)) {
return scratch
.setHex(pal.park)
.lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
}
if (elevation < 3) {
return scratch.setHex(pal.sand).lerp(new THREE.Color(pal.flats), elevation / 3);
}
return scratch
.setHex(pal.flats)
.lerp(new THREE.Color(pal.upland), Math.min(1, (elevation - 3) / 150));
}
/** The smooth flat polygon under each landmass — the crisp coastline. */
export function createShorePlates(world: World): THREE.Mesh {
const pal = paletteFor(world);
const positions: number[] = [];
for (const poly of world.city.landmasses) {
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
geo.rotateX(Math.PI / 2); // the shape's XY plane onto the scene's XZ ground
const pos = geo.getAttribute("position");
const index = geo.getIndex();
if (index) {
for (let i = 0; i < index.count; i++) {
const k = index.getX(i);
positions.push(pos.getX(k), pos.getY(k), pos.getZ(k));
}
} else {
for (let i = 0; i < pos.count; i++) positions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
}
geo.dispose();
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.computeVertexNormals();
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
mesh.name = "shorePlates";
return mesh;
}
/**
* The displaced ground. Indexed, and holding only the cells that are fully on
* land — a partial cell would poke a stair-step out over the water that the
* shore plate cannot hide.
*/
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 positions: number[] = [];
const colors: number[] = [];
const indices: number[] = [];
const scratch = new THREE.Color();
// Lattice corner -> emitted vertex, so the four cells sharing a corner share
// its vertex. Non-indexed, SF's terrain was 724k vertices for 241k triangles.
const vertexAt = new Int32Array((latSteps + 1) * (lngSteps + 1)).fill(-1);
const vertex = (i: number, j: number): number => {
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 e = height[k] ?? 0;
const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z);
const c = groundColor(world, pal, scratch, lat, lng, e);
colors.push(c.r, c.g, c.b);
const id = positions.length / 3 - 1;
vertexAt[k] = id;
return id;
};
for (let i = 0; i < latSteps; i++) {
for (let j = 0; j < lngSteps; j++) {
const a = i * (lngSteps + 1) + j;
const b = a + 1;
const c = a + (lngSteps + 1);
const d = c + 1;
if (!land[a] || !land[b] || !land[c] || !land[d]) continue;
indices.push(vertex(i, j), vertex(i + 1, j), vertex(i, j + 1));
indices.push(vertex(i, j + 1), vertex(i + 1, j), vertex(i + 1, j + 1));
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
mesh.name = "terrain";
return mesh;
}
/** Ocean and bay: one plane under everything, plus any inland water. */
export function createWater(world: World): THREE.Group {
const pal = paletteFor(world);
const group = new THREE.Group();
group.name = "water";
const { bounds } = world.city;
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
const sea = new THREE.Mesh(
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
new THREE.MeshLambertMaterial({ color: pal.sea }),
);
sea.rotation.x = -Math.PI / 2;
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
sea.receiveShadow = true;
group.add(sea);
for (const poly of world.city.inlandWater) {
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
geo.rotateX(Math.PI / 2);
const lake = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }),
);
lake.position.y = 0.05;
group.add(lake);
}
return group;
}
+239
View File
@@ -0,0 +1,239 @@
/**
* The contract between the engine and everything else.
*
* The engine renders a `City` and a list of `Marker`s. It does not know what a
* marker *is* — not that markers are companies, not that a red one means a
* rejection. That mapping lives in an adapter, outside this package, which 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.
*/
/** `[latitude, longitude]`, always in that order. */
export type LatLng = [number, number];
// ---- Geography ------------------------------------------------------------
/**
* A hill, as a radial peak summed into the heightfield.
*
* `elevation` is metres above sea level at the summit. `radius` is roughly
* where the hill meets the flats, in degrees of latitude.
*/
export interface Hill {
name: string;
lat: number;
lng: number;
elevation: number;
radius: number;
}
/**
* Where buildings go, how tall, and on what street grid.
*
* `gridAngle` is the district's street bearing in radians. It is per-district
* rather than per-city because that is the fact on the ground in San Francisco:
* the grid north of Market and the grid south of it are 46° out of true, and
* reproducing that is most of what makes the city recognisable from above.
*/
export interface District {
id: string;
name: string;
polygon: LatLng[];
/** Street bearing, radians clockwise from true north. */
gridAngle: number;
minHeight: number;
maxHeight: number;
/** Chance a given lot gets a tower rather than a low-rise. */
towerChance: number;
/** Facade palette key; see `blocks.ts`. */
palette: "downtown" | "residential" | "industrial";
/** Fraction of lots that get built on at all. Defaults to 0.88. */
coverage?: number;
}
/** A building placed by hand because the eye goes looking for it. */
export interface Landmark {
name: string;
lat: number;
lng: number;
/** Roof height in metres. */
height: number;
/** Half-width in degrees of longitude. */
footprint: number;
shape: "box" | "pyramid" | "tower" | "cylinder";
color?: number;
label?: boolean;
}
export interface Bridge {
name: string;
/** Deck centreline. Both ends should run onto land. */
path: LatLng[];
towers: LatLng[];
towerHeight: number;
deckHeight: number;
/** Suspension sag as a fraction of tower height. */
sag: number;
color: number;
}
export interface Road {
path: LatLng[];
width: number;
kind: "street" | "freeway";
}
/** A camera destination, and a sentence about why it is on the map. */
export interface Chapter {
id: string;
number: string;
label: string;
shortLabel: string;
focus: {
lat: number;
lng: number;
distance: number;
height: number;
rotation: number;
};
description: string;
}
/**
* A rectangle rendered at fine terrain resolution.
*
* SF declares one covering the whole city and behaves as if this did not exist.
* LA needs six — DTLA, Santa Monica, Culver, Irvine, Pasadena, downtown
* Riverside — with the basin between them coarse, because LA/OC/Riverside is
* roughly fourteen times SF's area and a uniform 45 m lattice over it would be
* 4.6M points. See ARCHITECTURE.md §5.
*/
export interface FocusRegion {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
/**
* Everything the engine needs to draw a place. Pure data — a city pack must
* contain no code, so that adding one is a contribution anybody can review.
*/
export interface City {
id: string;
name: string;
/** Map centre, and the origin of scene space. */
center: { lat: number; lng: number };
/** Scene bounds. Everything outside this is open water or off-frame. */
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
/**
* Degrees to scene units, for latitude. Longitude is derived as
* `latScale * cos(center.lat)` so the place keeps its true proportions.
*/
latScale: number;
/**
* How much taller than life the vertical is. Terrain and buildings share it,
* so they stay honest relative to each other.
*/
verticalExaggeration: number;
/** Ground-cell size inside a focus region, in degrees. */
cellLat: number;
cellLng: number;
/** Multiplier applied to cell size outside every focus region. 1 = uniform. */
coarseFactor?: number;
focusRegions?: FocusRegion[];
/** Distance from open water, in degrees, over which relief ramps to zero. */
coastFalloff: number;
landmasses: LatLng[][];
parks: LatLng[][];
inlandWater: LatLng[][];
hills: Hill[];
districts: District[];
landmarks: Landmark[];
bridges: Bridge[];
roads: Road[];
chapters: Chapter[];
/** Palette overrides; every field is optional. */
palette?: Partial<ScenePalette>;
}
export interface ScenePalette {
skyTop: number;
skyHorizon: number;
sea: number;
lake: number;
shore: number;
sand: number;
flats: number;
upland: number;
park: number;
parkHigh: number;
}
// ---- Markers --------------------------------------------------------------
/**
* A thing on the map.
*
* `colorKey` is deliberately opaque to the engine — it indexes into a palette
* the caller supplies. The engine will not learn what "rejected" means.
*/
export interface Marker {
id: string;
lat: number;
lng: number;
label: string;
colorKey: string;
/** Optional href for the detail card. */
url?: string;
/** Optional one-liner for the detail card. */
blurb?: string;
/**
* False when the position is a placeholder rather than a real address.
* Rendered distinctly, because inventing a location on a map whose premise
* is that it is real is worse than admitting the gap.
*/
located?: boolean;
}
/** Caller-supplied `colorKey` -> colour. */
export type MarkerPalette = Record<string, number>;
// ---- Flights --------------------------------------------------------------
export interface Aircraft {
id: string;
lat: number;
lng: number;
/** Barometric altitude in metres. */
altitude: number;
/** Degrees clockwise from true north. */
heading: number;
callsign?: string;
}
/**
* Where aircraft come from.
*
* An interface rather than a client because the obvious source — FlightRadar24
* — cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
* redistributing the data. This package ships a simulator and open community
* sources; anything commercial is an adapter in a private deployment. See
* ARCHITECTURE.md §4.
*/
export interface FlightSource {
/** Current traffic. Called on a timer; must be cheap and must not throw. */
poll(): Promise<Aircraft[]> | Aircraft[];
/** Seconds between polls. */
interval: number;
dispose?(): void;
}
+297
View File
@@ -0,0 +1,297 @@
/**
* A `City` turned into something the renderer can ask questions of: projection,
* polygon predicates, and the cached heightfield everything else samples.
*
* One `World` per city, built once. The engine's other modules take a `World`
* rather than importing constants, which is the whole reason a second city is
* a data file and not a fork.
*/
import type { City, LatLng } from "./types.ts";
export class World {
readonly city: City;
readonly lngScale: number;
/** Metres of latitude per scene unit. */
readonly metresPerUnit: number;
/** Longitude's foreshortening at this latitude, for distance maths. */
readonly lngSquash: number;
private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
private field: Float32Array | null = null;
private fieldLand: Uint8Array | null = null;
private latSteps = 0;
private lngSteps = 0;
constructor(city: City) {
this.city = city;
this.lngScale = city.latScale * Math.cos((city.center.lat * Math.PI) / 180);
this.metresPerUnit = 111_320 / city.latScale;
this.lngSquash = Math.cos((city.center.lat * Math.PI) / 180);
}
// ---- Projection ---------------------------------------------------------
projectX(lng: number): number {
return (lng - this.city.center.lng) * this.lngScale;
}
projectZ(lat: number): number {
return -(lat - this.city.center.lat) * this.city.latScale;
}
/** `[x, z]`. `x` runs east, `z` runs south, so north is `-z`. */
project(lat: number, lng: number): [number, number] {
return [this.projectX(lng), this.projectZ(lat)];
}
unproject(x: number, z: number): [number, number] {
return [this.city.center.lat - z / this.city.latScale, this.city.center.lng + x / this.lngScale];
}
projectPolygon(coords: LatLng[]): [number, number][] {
return coords.map(([lat, lng]) => this.project(lat, lng));
}
/** Metres above sea level to scene units, exaggeration applied. */
metres(m: number): number {
return (m / this.metresPerUnit) * this.city.verticalExaggeration;
}
unitsToMetres(u: number): number {
return (u * this.metresPerUnit) / this.city.verticalExaggeration;
}
// ---- Polygon predicates -------------------------------------------------
/**
* Bounding box, cached per polygon array.
*
* The heightfield asks `isLand` at hundreds of thousands of lattice points,
* and without this each one walked every edge of every landmass — the SF
* outline alone is fifty. Four comparisons first took the SF terrain build
* from 2.3 s to 1.0 s.
*/
private bbox(poly: LatLng[]): Float64Array {
const hit = this.bboxes.get(poly);
if (hit) return hit;
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const [lat, lng] of poly) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
const box = Float64Array.of(minLat, maxLat, minLng, maxLng);
this.bboxes.set(poly, box);
return box;
}
pointInPolygon(lat: number, lng: number, poly: LatLng[]): boolean {
const box = this.bbox(poly);
if (lat < box[0]! || lat > box[1]! || lng < box[2]! || lng > box[3]!) return false;
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
if (!a || !b) continue;
const [latI, lngI] = a;
const [latJ, lngJ] = b;
if (latI > lat !== latJ > lat) {
const x = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
if (lng < x) inside = !inside;
}
}
return inside;
}
pointInAny(lat: number, lng: number, polys: LatLng[][]): boolean {
for (const p of polys) if (this.pointInPolygon(lat, lng, p)) return true;
return false;
}
/** Shortest distance to a polygon's boundary, in degrees. */
private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number {
let best = Infinity;
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;
const dLat = bLat - aLat;
const dLng = bLng - aLng;
const lenSq = dLat * dLat + dLng * dLng;
let t = lenSq === 0 ? 0 : ((lat - aLat) * dLat + (lng - aLng) * dLng) / lenSq;
t = Math.max(0, Math.min(1, t));
const d = Math.hypot(lat - (aLat + t * dLat), lng - (aLng + t * dLng));
if (d < best) best = d;
}
return best;
}
isLand(lat: number, lng: number): boolean {
if (this.pointInAny(lat, lng, this.city.inlandWater)) return false;
return this.pointInAny(lat, lng, this.city.landmasses);
}
// ---- Relief -------------------------------------------------------------
/**
* Ground elevation in metres, from the city's hills.
*
* Overlapping hills combine as `tallest + 35% of the rest`. A straight sum
* puts Twin Peaks and Mount Sutro — 1.2 km apart, 281 m and 275 m — at a
* fictional 500 m; a plain max leaves a suspicious notch between them. The
* blend keeps summits near their real heights and still builds the ridge
* that actually connects them.
*/
elevationAt(lat: number, lng: number): number {
let peak = 0;
let total = 0;
for (const hill of this.city.hills) {
const dLat = lat - hill.lat;
const dLng = (lng - hill.lng) * this.lngSquash;
const d = Math.hypot(dLat, dLng) / hill.radius;
if (d >= 1) continue;
const f = (1 - d * d) ** 2; // 1 at the summit, 0 with zero gradient at the edge
const h = hill.elevation * f;
total += h;
if (h > peak) peak = h;
}
if (peak === 0) return 0;
const h = peak + (total - peak) * 0.35;
// Roughen. Bare radial bumps read as golf balls; real hills have spurs and
// gullies. Multiplied rather than added so the flats stay flat instead of
// growing dunes.
const rough = 0.82 + 0.36 * fbm(lat / 0.0042, lng / 0.0053);
return h * rough * this.coastalFalloff(lat, lng);
}
/** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
private coastalFalloff(lat: number, lng: number): number {
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 (!Number.isFinite(d)) return 0;
const t = Math.min(1, d / this.city.coastFalloff);
return t * t * (3 - 2 * t);
}
// ---- Cached heightfield -------------------------------------------------
/**
* `elevationAt` is not cheap — every hill, four octaves of noise, and a
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
* thousands of lattice points, and then every building, road sample and
* camera target wants it again. Computed once, read back bilinearly.
*/
private buildField(): { height: Float32Array; land: Uint8Array } {
if (this.field && this.fieldLand) 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 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;
for (let j = 0; j <= this.lngSteps; j++) {
const lng = bounds.minLng + j * cellLng;
const k = i * w + j;
const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
height[k] = onLand ? this.elevationAt(lat, lng) : 0;
}
}
this.field = height;
this.fieldLand = land;
return { height, land };
}
/** Lattice dimensions, for the terrain mesh builder. */
lattice(): { latSteps: number; lngSteps: number; height: Float32Array; land: Uint8Array } {
const { height, land } = this.buildField();
return { latSteps: this.latSteps, lngSteps: this.lngSteps, 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 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 a = height[i * w + j] ?? 0;
const b = height[i * w + j + 1] ?? 0;
const c = height[(i + 1) * w + j] ?? 0;
const d = height[(i + 1) * w + j + 1] ?? 0;
return (a * (1 - tj) + b * tj) * (1 - ti) + (c * (1 - tj) + d * tj) * ti;
}
/** Scene-space ground height. What everything stands on. */
groundAt(lat: number, lng: number): number {
return this.metres(this.elevationSampled(lat, lng));
}
}
// ---- Deterministic noise and randomness -----------------------------------
function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return s - Math.floor(s);
}
function valueNoise(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = xf * xf * (3 - 2 * xf);
const v = yf * yf * (3 - 2 * yf);
const a = hash2(xi, yi);
const b = hash2(xi + 1, yi);
const c = hash2(xi, yi + 1);
const d = hash2(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
/** Four octaves is enough texture at 45 m cells; more is invisible. */
export function fbm(x: number, y: number): number {
let f = 0;
let amp = 0.5;
let freq = 1;
for (let i = 0; i < 4; i++) {
f += amp * valueNoise(x * freq, y * freq);
freq *= 2.1;
amp *= 0.5;
}
return f;
}
/**
* Deterministic PRNG (mulberry32). Every scatter in the scene draws from one of
* these so a reload produces the same city. A map that reshuffles its own
* buildings between visits is a lava lamp, not a map.
*/
export function seededRandom(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
+88
View File
@@ -0,0 +1,88 @@
/**
* The standalone demo: San Francisco, simulated traffic, chapter legend.
*
* Deliberately ships **no company data**. Markers are demonstrated using the
* city's own landmarks — buildings, not businesses — because company positions
* are geocoded (ODbL) 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.
*/
import { createScene } from "./engine/scene.ts";
import { SimulatedFlights, type SimRoute } from "./engine/flights.ts";
import type { Marker, MarkerPalette } from "./engine/types.ts";
import SAN_FRANCISCO from "./cities/sf.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 MARKER_PALETTE: MarkerPalette = {
landmark: 0xf2b134,
neutral: 0x9aa4ad,
};
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
const scene = createScene(canvas, {
city: SAN_FRANCISCO,
markerPalette: MARKER_PALETTE,
flights: new SimulatedFlights(ROUTES),
onMarkerPick: (marker) => {
const card = document.querySelector<HTMLElement>("#detail");
if (!card) return;
if (!marker) {
card.hidden = true;
return;
}
card.hidden = false;
card.textContent = marker.label;
},
});
// Demo markers: the city's own named buildings.
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,
}));
scene.setMarkers(demoMarkers);
// ---- Chapter legend -------------------------------------------------------
const nav = document.querySelector<HTMLElement>("#chapters");
const blurb = document.querySelector<HTMLElement>("#blurb");
function renderLegend(activeId: string) {
if (!nav) return;
nav.replaceChildren();
for (const chapter of scene.chapters) {
const button = document.createElement("button");
button.className = chapter.id === activeId ? "chapter active" : "chapter";
button.innerHTML = `<span class="num">${chapter.number}</span><span>${chapter.shortLabel}</span>`;
button.addEventListener("click", () => scene.flyTo(chapter.id));
nav.append(button);
}
const active = scene.chapters.find((c) => c.id === activeId);
if (blurb && active) blurb.textContent = active.description;
}
renderLegend(scene.current());
scene.onChapterChange(renderLegend);