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:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user