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:
+1066
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user