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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.local
# Company logos are TRADEMARKS and are never committed — they are fetched at
# runtime and cached in the browser. An Apache-2.0 repo containing them would
# be relicensing marks it does not own. See ARCHITECTURE.md §3.1.
public/logos/
+274
View File
@@ -0,0 +1,274 @@
# Lumbridge Simulate Engine — architecture
**LSE** is the third of the three: `lumbridge-compute`, `lumbridge-bench`,
`lumbridge-simulate`. A 3D engine for walkable places — a city from above, an
office from inside, one renderer and one asset library.
Ships at `lumbridgecorp.com/live`. Apache 2.0, self-hostable, Gitea.
---
## 1. Why this is its own repo, and why it is public
LSE began as `/live` inside Workie, the private career portal. That is now
**deleted**, not deprecated — a 3D city engine has no business living in a job
tracker, and Workie is a private repo besides.
**You cannot cleanly Apache-2.0 a subdirectory of a private repo.** The licence
attaches to a distribution, and a consumer has to be able to clone the thing the
licence covers. A folder inside `lumbridgecorp-website` or inside
`lumbridge-runtime` would mean every release is a manual extraction, and every
extraction is a chance to ship something private by accident. The point of this
project — a reusable asset library other people can build on and self-host — needs
a repo that *is* the open-source artefact.
So: **`lumbridge-public/lumbridge-simulate` on the fleet's own Gitea.** Not
GitHub. Self-hosting the forge is the same claim the engine makes: the whole
thing runs on hardware you own, and nothing about it requires somebody else's
platform to stay alive.
### World 1 is this
This is the part that makes LSE load-bearing rather than a demo. The existing
Lumbridge world is a Phaser 2D office, and its tileset is what stops it being
open-sourceable. World 1 becomes **the San Francisco city view plus the office
interiors**, rendered here, with original assets.
That reframes the whole repo: LSE is not a map that happens to live next to the
platform, it is the platform's renderer. The city is the outside of the world
and the offices are the inside of it, and a tenant moves between them.
---
## 2. Layout
```
lumbridge-simulate/
├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights.
│ ├── types.ts # City, District, Hill, Marker, FlightSource
│ ├── world.ts # projection + cached heightfield, built per city
│ ├── terrain.ts # relief, coastline, parks
│ ├── blocks.ts # the built city, on a per-district street lattice
│ ├── structures.ts # bridges, streets, freeways
│ ├── markers.ts # pins. Takes Marker[], knows nothing about companies.
│ ├── flights.ts # FlightSource interface + a simulated implementation
│ └── scene.ts # lights, sky, camera flights, render loop
├── src/interiors/ # the inside of the world — floorplans, desks, presence
├── src/assets/ # the library. Original meshes and materials, Apache 2.0.
├── src/cities/ # data packs. Pure geography, no code.
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
│ └── la.ts # LA / OC / Riverside
├── src/adapters/ # where outside data plugs in
│ └── workie.ts # Workie API -> Marker[]
└── src/main.ts # the standalone demo app
```
The split that matters: **`engine` never imports `cities`, and neither imports
`adapters`.** A city is data handed to the engine. A marker is a `{id, lat, lng,
label, colorKey}` handed to the engine. The engine has no idea Workie exists,
which is what makes it publishable, self-hostable by someone with no Lumbridge
account at all, and free of anyone's private data.
`interiors` and `assets` sit beside `engine` rather than inside it: an office
and a city share the projection, camera, lighting and render loop, and differ
only in what they put in the scene. That is why `scene.ts` owns the loop and
knows nothing about terrain specifically.
---
## 3. Three rules that keep Apache 2.0 honest
Apache 2.0 is a promise that everything in the repo is ours to give away. Three
things would quietly break that promise, and all three are easy to walk into.
### 3.1 No trademarks in the repo
Company logos are **trademarks**, not code. Salesforce's logo is not ours to
relicense no matter how the file got here, and an Apache-2.0 repo containing
`logos/salesforce.svg` is making a claim it cannot back.
The NYC atlas ships 47 of these in `public/logos/`. We do not.
- Logos are fetched **at runtime**, client-side, and cached in the browser.
- The repo carries a fetch script and zero logo files. `public/logos/` is
`.gitignore`d, and CI fails if anything lands there.
- `NOTICE` carries the standard "trademarks are the property of their
respective owners; their use here is nominative" line.
### 3.2 No OSM-derived coordinates in the repo
This is the subtle one, and it is the reason the geography in `cities/sf.ts` is
hand-traced rather than imported.
OpenStreetMap data — including anything that comes out of Nominatim geocoding —
is **ODbL**. ODbL is share-alike: a "derivative database" has to be released
under ODbL too. Committing a table of company lat/lngs geocoded from Nominatim
into an Apache-2.0 repo mixes an incompatible share-alike obligation into a
permissive one. That is exactly the kind of thing that makes a repo unusable for
the people we want using it.
So:
- **Geography** (coastlines, hills, districts) is traced by hand from scratch.
Original expression, ours, Apache 2.0. This is already true for SF.
- **Elevation**, when we want real terrain, comes from **USGS/SRTM**, which is
US-government public domain. Not OSM.
- **Geocoded company coordinates never enter this repo.** They live in Workie's
private database and arrive over the API at runtime.
Note where that lands: the licence constraint and the privacy constraint want
exactly the same thing. Company positions and pipeline status both stay behind
the API; the open-source repo holds the city and the renderer. That is a nice
result and it should be defended, because the tempting shortcut — "just commit a
`sf-companies.json`" — breaks both at once.
### 3.3 The engine takes no position on what a marker means
`markers.ts` renders `Marker[]`. A marker has a `colorKey`, not a `status`. The
mapping from "rejected" to red lives in the *adapter*, in the consuming app.
That is what lets the same engine serve the private Workie build (coloured by
pipeline state), the public Lumbridge demo (coloured by sector), and whatever
someone else builds from this repo, without any of them being a fork.
---
## 4. Flights: FlightRadar24 is the wrong source for this
Real aircraft over the Bay is a great idea. FR24 specifically is a problem, and
it is worth being clear about why before any code is written against it.
FlightRadar24's terms prohibit scraping and prohibit redistributing their data;
their commercial API is licensed per-seat and explicitly does not grant
redistribution. An Apache-2.0 repo shipping an FR24 client is publishing
instructions for violating a ToS, and any FR24 data that reached the browser
could not be relicensed onward. Neither is fatal to a private deployment, but
both are fatal to the open-source story that is the point of this repo.
The engine therefore takes a **`FlightSource` interface**, and the repo ships
only sources it can actually give away:
| source | licence | notes |
| --- | --- | --- |
| **`SimulatedFlights`** | ours, Apache 2.0 | great-circle tracks on real SFO/OAK/SJC approach and departure paths. **This is what ships first**, and it is genuinely enough — the map wants convincing motion, not a spotter's log. |
| **`adsb.lol` / `airplanes.live`** | community, open terms | free, no key, real aircraft. The default "real data" adapter. |
| **OpenSky Network** | free, non-commercial | fine for a demo, awkward for a commercial product page. |
| **your own receiver** | *no licence at all* | an RTL-SDR + `dump1090` on any fleet box in the Bay produces first-party ADS-B. Nothing to comply with, and it is very on-brand. |
| FlightRadar24 | commercial, no redistribution | if it is ever wanted, it is a **private adapter in the Lumbridge deployment**, not in this repo. |
Recommendation: build `SimulatedFlights` now, `adsb.lol` next, and treat a
receiver as the eventual real answer.
---
## 5. LA is not "SF but more data"
SF is 0.20° × 0.36°. At the 45 m cells the SF heightfield uses, that is 336k
lattice points and a 1.0 s build.
LA + Orange County + Riverside is roughly 0.5° × 1.9° — about **14× the area**.
The same approach would be 4.6M lattice points and a mesh nobody can load. This
is the one place where the engine as written does not simply extend, and it is
better to know that before writing `cities/la.ts` than after.
Two changes make it work, and both are cheap now:
1. **Cell size is per-city, not a constant.** LA's basin gets coarse cells;
nothing is lost, because LA's relief is mountains at the edges rather than
SF's hills every four blocks.
2. **Cities declare `focusRegions`** — a handful of boxes (DTLA, Santa Monica,
Culver, Irvine, Pasadena) rendered at fine resolution, with the basin between
them coarse. This is the LOD story, and it is a per-city data declaration
rather than engine machinery.
SF gets one focus region covering the whole city and behaves exactly as it does
now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs.
---
## 6. How Workie feeds it
Workie stays the system of record for companies. LSE never gets a database.
```
Workie GET /api/live/markers (private, tailnet) -> status colours
Workie GET /api/public/markers (public, allowlist) -> sector colours
|
v
adapters/workie.ts -> Marker[]
|
v
engine/markers.ts
```
The public endpoint goes through the **same `export-site.ts` field allowlist**
that already guards radar.karti.ai and work.karti.ai — the one that is
fail-closed and aborts a deploy rather than shipping an unknown field. No second
implementation of that gate. That was the argument for keeping `/live` inside
Workie originally, and it still holds — but only for the *data path*. The
renderer left; the gate stayed where it was.
Self-hosters get neither endpoint and do not need one: `setMarkers()` takes an
array, and where it comes from is the deployment's business.
`lumbridgecorp.com/live` is a static page, so it calls a small
`lumbridge-simulate` service behind the existing Caddy `handle /api/*` — the
same pattern `lumbridge-intake.service` already uses on cloud-2 — for flights
and for proxying the public marker feed.
---
## 7. Interiors — and why the asset library is the real product
The existing Lumbridge world is a Phaser 4 2D office, and Karti's read is right:
its tileset is the thing that stops it being open-sourceable. Redrawing that in
3D with original assets is not a graphics exercise, it is a **licensing
unlock** — it is what lets the walkable-office half of Lumbridge ship under
Apache 2.0 at all.
That makes `src/assets` the highest-leverage part of this repo over time: an
original, consistent, Apache-2.0 library of desks, chairs, partitions, screens,
doors, lighting rigs, floor and wall materials. Built once, used by every
Lumbridge world and by anyone else who wants one.
Interiors share the engine's projection, camera and render loop, and swap the
city layer for a floorplan layer. Same `Marker` type — a desk with a person at
it is a marker with a different `colorKey`. The city view and the office view
are the outside and the inside of one world, and moving between them is a camera
transition, not a different application.
Reference point: the per-office spatial products in this space (Simile and
friends) are the shape to aim at. The difference LSE is going for is that this
one is Apache 2.0 and self-hostable — you can run your own world on your own
hardware, which is the same claim `lumbridge-compute` makes about compute.
This is the next phase after the city lands, and it is where `src/assets` starts
earning its keep.
---
## 8. Order of work
1. **Port** SF out of Workie into `engine` + `cities/sf`, parameterised by city;
delete Workie's `/live`. *(this commit)*
2. **Markers + simulated flights.** A demo worth showing, with no data pipeline
and no licence questions. *(this commit)*
3. **Mount at `lumbridgecorp.com/live`**, with a small `lumbridge-simulate`
service behind Caddy for flights.
4. **Interiors + the asset library** — the office view, original assets, and
World 1 moving off Phaser. This is the one that unlocks the platform.
5. **Workie adapter**, once its geocoding pipeline lands. Workie gets an API,
not a renderer.
6. **`cities/la`** — LA / OC / Riverside. Needs `focusRegions` from §5 first.
7. **NYC.**
## 9. Open questions
1. **Package name.** `@lumbridge/simulate` implies an npm publish; consuming
straight from Gitea is simpler until someone outside asks for a registry.
2. **Licence for the asset library specifically.** Apache 2.0 covers code
cleanly; art is sometimes better served by CC0 or CC-BY so it can be reused
outside software. Worth deciding before the first mesh lands, because
relicensing art after contributors exist is painful.
3. **How much of World 1 moves at once.** The Phaser world is live and has real
tenants; the city view can ship at `/live` well before any office does.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+72
View File
@@ -0,0 +1,72 @@
LSE — Lumbridge Simulate
Copyright 2026 Lumbridge Corp.
This product includes software developed at Lumbridge Corp.
Licensed under the Apache License, Version 2.0 (see LICENSE).
ORIGIN AND ORIGINALITY
----------------------
LSE's city view was inspired by the interaction model of two prior works —
Levels.fyi Atlas, and Nutlope's NYC AI Atlas (https://github.com/Nutlope/
nyc-ai-atlas), which itself credits Levels.fyi. What was taken is the *idea*:
a left-hand chapter legend, animated camera flights into map areas, clickable
place labels, and ambient motion in the scene.
No code, geometry, or asset from either work is present here. LSE's renderer,
its terrain system, its block-lattice building placement, and every coordinate
in every city pack were written from scratch. The two projects do not even
share a subject: the NYC atlas renders hand-traced Manhattan on flat ground,
and LSE's premise is relief — a city you cannot read without its section.
Stating this plainly matters, because at the time of writing the NYC AI Atlas
repository carries no LICENSE file. Absent a licence grant, the default is that
no rights are given, so nothing from it could have been reused even had we
wanted to. Attribution here is courtesy toward a good idea, not a licence
obligation.
GEOGRAPHIC DATA
---------------
All geography in src/cities/ is original expression, traced by hand.
It is deliberately NOT derived from OpenStreetMap. OSM data — including
coordinates returned by Nominatim geocoding — is licensed under the Open
Database License (ODbL), whose share-alike terms would attach to any derivative
database and cannot be reconciled with the permissive licence above.
Where real elevation data is used in future, it will come from USGS/SRTM
sources, which are United States government works in the public domain.
TRADEMARKS
----------
This repository contains no third-party logos, wordmarks, or brand assets.
Organisation logos displayed by applications built on LSE are fetched at
runtime and are never redistributed as part of this project. All trademarks,
service marks, and trade names are the property of their respective owners.
Any use is nominative — to identify the organisation referred to — and does not
imply affiliation with or endorsement by the trademark holder.
THIRD-PARTY DEPENDENCIES
------------------------
three.js — MIT License — Copyright (c) 2010-2026 three.js authors
https://github.com/mrdoob/three.js
Build tooling (Vite, TypeScript, esbuild) is a development dependency only and
is not redistributed in built output.
AIRCRAFT DATA
-------------
The flight sources shipped in this repository are a simulator (original work)
and clients for open community ADS-B feeds. No commercial aviation data
provider's data is included or redistributed, and no client for a provider
whose terms prohibit such use is present.
+87
View File
@@ -0,0 +1,87 @@
# Lumbridge Simulate Engine
**Lumbridge Simulate** — a 3D engine for walkable places. Cities from above,
buildings from inside, one renderer and one asset library.
Apache 2.0. Runs at [lumbridgecorp.com/live](https://lumbridgecorp.com/live).
![San Francisco](docs/sf.jpg)
---
## What it is
An engine plus data packs. The engine renders terrain, coastline, a built city
on real street grids, bridges, roads, markers and air traffic. A *city pack* is
pure data — coastlines, hills, districts, landmarks, camera chapters — so adding
a city is a data contribution anyone can review, not a fork.
San Francisco ships today. Los Angeles / Orange County / Riverside is next; New
York after that.
## Quick start
```bash
npm install
npm run dev
```
## Using the engine
```ts
import { createScene } from "@lumbridge/simulate/engine/scene.ts";
import SAN_FRANCISCO from "@lumbridge/simulate/cities/sf.ts";
const scene = createScene(canvas, {
city: SAN_FRANCISCO,
markerPalette: { hiring: 0x4ade80, closed: 0xef4444 },
});
scene.setMarkers([
{ id: "1", lat: 37.7765, lng: -122.4241, label: "Somewhere", colorKey: "hiring" },
]);
```
The engine renders `Marker[]` and looks colours up by `colorKey` in a palette
you supply. It does not know what your markers *mean* — that mapping lives in
your adapter. This is what lets one renderer serve a private map coloured by
one scheme and a public map coloured by another, without either being a fork.
## Adding a city
Write `src/cities/<id>.ts` exporting a `City`. Trace the coastline and parks by
hand, place hills as radial peaks, and give each district its street bearing.
Two rules, and they are not stylistic:
- **Do not import geometry from OpenStreetMap.** OSM and Nominatim output is
ODbL — share-alike, and incompatible with this repo's licence.
- **Do not commit logos or brand assets.** They are trademarks, not code.
See [ARCHITECTURE.md](ARCHITECTURE.md) §3 for the full reasoning, and
[NOTICE](NOTICE) for the attribution and data-provenance statement.
## Aircraft
The engine takes a `FlightSource`. Two ship here: `SimulatedFlights` (original,
flies real approach and departure corridors) and `AdsbFlights` (open community
ADS-B feeds such as adsb.lol).
FlightRadar24 is deliberately absent — their terms forbid scraping and forbid
redistributing their data, so a client for it cannot live in an Apache-2.0
repository. Commercial sources belong in private deployments. The best long-term
answer is an RTL-SDR receiver: first-party data with nothing to comply with.
## Layout
```
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene
src/cities/ data packs — pure geography, no code
src/adapters/ where outside data plugs in
```
`engine` never imports `cities`; neither imports `adapters`.
## Licence
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumbridge Simulate — San Francisco</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #d9e6ee; overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
#scene { display: block; width: 100vw; height: 100vh; }
#panel { position: fixed; top: 0; left: 0; padding: 1rem; width: 19rem;
display: flex; flex-direction: column; gap: 0.25rem; pointer-events: none; }
#panel > * { pointer-events: auto; }
.card { background: rgba(8,12,16,0.55); backdrop-filter: blur(6px);
border-radius: 6px; padding: 0.75rem; color: rgba(255,255,255,0.72); font-size: 11px;
line-height: 1.5; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #f2b134; }
h1 + p { margin: 0.15rem 0 0; color: rgba(255,255,255,0.55); }
#chapters { display: flex; flex-direction: column; gap: 1px; background: rgba(8,12,16,0.45);
backdrop-filter: blur(6px); border-radius: 6px; padding: 4px; }
.chapter { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.5rem;
background: none; border: 0; border-radius: 4px; cursor: pointer; text-align: left;
font: inherit; font-size: 12px; color: rgba(255,255,255,0.7); }
.chapter:hover { background: rgba(255,255,255,0.09); }
.chapter.active { background: rgba(242,177,52,0.2); color: #ffd68a; }
.num { font-size: 10px; opacity: 0.6; font-variant-numeric: tabular-nums; }
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
color: rgba(20,30,40,0.5); }
</style>
</head>
<body>
<canvas id="scene"></canvas>
<div id="panel">
<div class="card">
<h1>San Francisco</h1>
<p>Lumbridge Simulate Engine</p>
</div>
<nav id="chapters"></nav>
<p class="card" id="blurb"></p>
</div>
<div class="card" id="detail" hidden></div>
<p id="hint">drag to orbit · scroll to zoom</p>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1255
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@lumbridge/simulate",
"version": "0.1.0",
"description": "Lumbridge Simulate Engine (LSE) — a 3D engine for walkable places. Cities from above, buildings from inside.",
"license": "Apache-2.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.182.0"
},
"devDependencies": {
"@types/three": "^0.182.0",
"typescript": "^5.7.2",
"vite": "^7.3.6"
}
}
+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);
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
export default defineConfig({
// Mounted under lumbridgecorp.com/live/ in production; the trailing
// slash matters, since every asset URL is resolved against it.
base: process.env.LSE_BASE ?? "/",
build: { outDir: "dist", target: "es2022" },
});