Enhance README with detailed testing instructions and clarify world generation process; implement chunk boundary handling in OsmWorldBuilder; integrate Vitest for testing in the web project; add theme toggle functionality and improve styling in the web interface.
This commit is contained in:
@@ -46,12 +46,21 @@ dotnet run --project src/TheLivingWorld.Api
|
||||
npm --prefix src/TheLivingWorld.Web run dev
|
||||
```
|
||||
|
||||
Tests:
|
||||
Tests — the backend under xUnit, the client under Vitest:
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
```bash
|
||||
npm --prefix src/TheLivingWorld.Web test
|
||||
```
|
||||
|
||||
The client tests cover the pure half of the renderer: geometry helpers, camera maths, layer ordering and the
|
||||
palettes. Modules that hold PixiJS values are deliberately kept out of them, which is why `layers.ts` imports
|
||||
`Container` as a type only and the container construction lives in `mapView.ts` — the ordering rules stay
|
||||
testable without a browser.
|
||||
|
||||
## How a world is made
|
||||
|
||||
1. **Fetch.** `OverpassClient` posts one bounding-box query to Overpass and streams the response into
|
||||
@@ -63,6 +72,9 @@ dotnet test
|
||||
3. **Import.** `OsmWorldBuilder` reads each element's tags, decides what it is, and creates one ECS entity per
|
||||
feature. Multipolygon relations are stitched into rings by `RingAssembler`; everything is clipped to the
|
||||
world square by `GeometryClipper`, so a highway crossing town does not drag geometry 40 km off the map.
|
||||
Lines are then cut again at chunk boundaries — a road that spans the map becomes one entity per chunk it
|
||||
crosses, which is what keeps a chunk's extent close to its own square. Neighbouring pieces overlap by a
|
||||
metre and a half so the seam is covered rather than left as a hairline gap.
|
||||
4. **Systems.** `ComputeBoundsSystem` fills each entity's extent, `AssignChunksSystem` buckets it into the
|
||||
chunk grid.
|
||||
5. **Export.** `ChunkExporter` walks the ECS world and writes one JSON file per chunk, plus an index.
|
||||
@@ -106,13 +118,43 @@ in wire format and streamed straight from disk.
|
||||
|
||||
## The client
|
||||
|
||||
`MapView` owns one scaled container holding five layers — land cover, water, road casings, road fills,
|
||||
buildings — so a building in one chunk never ends up under a park from the next. `Camera` is the only place
|
||||
the Y flip lives; everything else thinks in map coordinates.
|
||||
`MapView` owns one scaled container holding the layer stack from `layers.ts`, plus a screen-space layer for
|
||||
place names above it. `Camera` is the only place the Y flip lives; everything else thinks in map coordinates.
|
||||
|
||||
**Layers are global, not per chunk.** Every chunk paints into the same ordered set of containers rather than
|
||||
into a container of its own. That is what makes junctions correct: with per-chunk containers the ordering
|
||||
would only hold inside a chunk, so a side street loaded after a trunk road would paint straight over it
|
||||
wherever the two meet. Roads are sorted into importance bands — tunnel, minor, local, secondary, major,
|
||||
bridge — and within each band every casing goes down before any fill, so the fills merge into one continuous
|
||||
surface. Land cover gets the same treatment in three bands: zoning blocks, natural cover, then parks and
|
||||
pitches.
|
||||
|
||||
`ChunkManager` fetches chunks as the camera reaches them and drops their graphics once they are well out of
|
||||
view, keeping the parsed data cached so panning back is instant. Detail thins out as you zoom away: footpaths
|
||||
disappear first, then small buildings, and stroke widths gain a floor so hairlines stay visible.
|
||||
view, keeping the parsed data cached so panning back is instant. When a chunk is redrawn — a zoom step or a
|
||||
theme switch — the new set fades in over the old one rather than replacing it outright.
|
||||
|
||||
Detail thins out as you zoom away: footpaths disappear first, then small buildings, and stroke widths gain a
|
||||
floor so hairlines stay visible. At street level the map picks up the things that only read close up:
|
||||
|
||||
- buildings extrude, with walls drawn down from every footprint edge to a roof lifted by the building's height
|
||||
- footways, paths, steps and cycleways switch to dashed lines so they never read as pale streets
|
||||
- railways become a dark bed with light sleepers dashed over it
|
||||
- one-way streets grow chevrons pointing the way traffic runs
|
||||
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
|
||||
are left alone, because a gridded town is full of genuine right angles
|
||||
|
||||
Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in
|
||||
two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then
|
||||
arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
|
||||
label already placed, or any street name too long for the road it belongs to. Because a road is split across
|
||||
chunks, the pieces are folded back together by OSM id so a street gets one label rather than one per chunk.
|
||||
That pass runs on the same slow timer as chunk bookkeeping. `LabelLayer` then moves the chosen labels to
|
||||
follow the camera every frame, which is nearly free — without that split they lag a fast pan by up to a tenth
|
||||
of a second and snap back when the next placement lands.
|
||||
|
||||
Both palettes live in `theme.ts` and nothing else in the renderer names a colour. Switching theme changes the
|
||||
render profile key, which is the same signal a zoom change uses, so every loaded chunk redraws through the
|
||||
usual dissolve instead of a special case. The page chrome follows via a `data-theme` attribute.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -130,6 +172,10 @@ coordinates and is not committed.
|
||||
|
||||
- The Overpass response is parsed in one pass rather than streamed. Fine for the small towns this targets; a
|
||||
dense 20 km city would want a streaming reader.
|
||||
- Chunk assignment buckets a feature by the centre of its extent and never splits geometry, so a long road
|
||||
overhangs its chunk. The exported chunk bounds are widened to match and the client culls against those.
|
||||
- Buildings are flat footprints shaded by height. No 3D, no roofs.
|
||||
- Lines are split at chunk boundaries, but polygons are not: a large forest or landuse block still belongs
|
||||
whole to the chunk holding the centre of its extent and overhangs its neighbours. The exported chunk bounds
|
||||
are widened to match and the client culls against those. Splitting polygons too would risk hairline seams
|
||||
between the filled pieces.
|
||||
- Building extrusion is a flat fake — walls swept in one fixed direction, no perspective and no roof shapes.
|
||||
- Labels are placed along a straight line at the middle of a road, not curved along its path, so a name on a
|
||||
sharply bending street sits at the average angle rather than following it.
|
||||
|
||||
@@ -16,6 +16,12 @@ public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||
/// <summary>Rings smaller than this are noise at any zoom the client offers.</summary>
|
||||
private const float MinimumAreaSquareMeters = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// How far a split line reaches past its chunk. Neighbouring pieces overlap by this much so the seam at a
|
||||
/// chunk boundary is covered rather than left as a hairline gap.
|
||||
/// </summary>
|
||||
private const float ChunkSeamOverlapMeters = 1.5f;
|
||||
|
||||
public WorldStats Populate(GameWorld world, IEnumerable<OverpassElement> elements)
|
||||
{
|
||||
var half = (float)(world.Metadata.SizeMeters / 2.0);
|
||||
@@ -206,12 +212,12 @@ public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||
GameWorld world, OsmSource source, Road road, Vector2[] points,
|
||||
string? name, in RectBounds clip, Counters counters)
|
||||
{
|
||||
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
|
||||
foreach (var piece in SplitForChunks(world, points, clip))
|
||||
{
|
||||
world.Ecs.Create(
|
||||
source,
|
||||
road,
|
||||
new Polyline(world.Shapes.Add(run)),
|
||||
new Polyline(world.Shapes.Add(piece)),
|
||||
new Bounds(),
|
||||
new InChunk(),
|
||||
new DisplayName(name));
|
||||
@@ -224,12 +230,12 @@ public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||
GameWorld world, OsmSource source, Water water, Vector2[] points,
|
||||
string? name, in RectBounds clip, Counters counters)
|
||||
{
|
||||
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
|
||||
foreach (var piece in SplitForChunks(world, points, clip))
|
||||
{
|
||||
world.Ecs.Create(
|
||||
source,
|
||||
water,
|
||||
new Polyline(world.Shapes.Add(run)),
|
||||
new Polyline(world.Shapes.Add(piece)),
|
||||
new Bounds(),
|
||||
new InChunk(),
|
||||
new DisplayName(name));
|
||||
@@ -238,6 +244,43 @@ public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clips a line to the world square and then to each chunk it passes through. Without this a road belongs
|
||||
/// wholly to the chunk holding the centre of its extent, so one highway can stretch a chunk's bounds
|
||||
/// across the entire map and force the client to load it from any viewpoint. Each piece ends up inside
|
||||
/// the chunk it was cut for, so <see cref="Systems.AssignChunksSystem"/> still places it by centre.
|
||||
/// </summary>
|
||||
private static List<Vector2[]> SplitForChunks(GameWorld world, Vector2[] points, in RectBounds clip)
|
||||
{
|
||||
var grid = world.Grid;
|
||||
var pieces = new List<Vector2[]>();
|
||||
|
||||
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
|
||||
{
|
||||
var extent = RectBounds.FromPoints(run);
|
||||
var first = grid.CoordOf(new Vector2(extent.MinX, extent.MinY));
|
||||
var last = grid.CoordOf(new Vector2(extent.MaxX, extent.MaxY));
|
||||
|
||||
if (first == last)
|
||||
{
|
||||
pieces.Add(run);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var y = first.Y; y <= last.Y; y++)
|
||||
{
|
||||
for (var x = first.X; x <= last.X; x++)
|
||||
{
|
||||
var cell = grid.BoundsOf(new ChunkCoord(x, y));
|
||||
cell.Expand(ChunkSeamOverlapMeters);
|
||||
pieces.AddRange(GeometryClipper.ClipPolyline(run, cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pieces;
|
||||
}
|
||||
|
||||
private static int[]? StoreHoles(GameWorld world, List<Vector2[]>? holes, in RectBounds clip)
|
||||
{
|
||||
if (holes is null || holes.Count == 0) return null;
|
||||
|
||||
@@ -11,8 +11,13 @@
|
||||
|
||||
<aside id="panel" class="panel">
|
||||
<header class="panel__header">
|
||||
<div class="panel__titles">
|
||||
<h1>The Living World</h1>
|
||||
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
|
||||
</div>
|
||||
<button id="theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
|
||||
☾
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form id="generate-form" class="form">
|
||||
|
||||
Generated
+394
-1
@@ -13,9 +13,17 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.2.1"
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
@@ -277,12 +285,44 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/earcut": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
@@ -293,6 +333,119 @@
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.71",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||
@@ -308,6 +461,33 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -324,12 +504,39 @@
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -645,6 +852,16 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -664,12 +881,33 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
|
||||
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
|
||||
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -778,6 +1016,13 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -788,6 +1033,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-lru": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||
@@ -797,6 +1056,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -814,6 +1090,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -912,6 +1198,113 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi.js": "^8.19.0"
|
||||
@@ -15,6 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.2.1"
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import './styles.css';
|
||||
import { api, waitForWorld } from './api/client';
|
||||
import type { WorldSummary } from './api/types';
|
||||
import { MapView, type MapStatus } from './map/mapView';
|
||||
import { THEMES, type ThemeName } from './map/theme';
|
||||
|
||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||
const THEME_KEY = 'the-living-world:theme';
|
||||
|
||||
const elements = {
|
||||
stage: required<HTMLDivElement>('stage'),
|
||||
@@ -17,6 +19,7 @@ const elements = {
|
||||
worldList: required<HTMLUListElement>('world-list'),
|
||||
status: required<HTMLElement>('status'),
|
||||
hud: required<HTMLDivElement>('hud'),
|
||||
themeToggle: required<HTMLButtonElement>('theme-toggle'),
|
||||
};
|
||||
|
||||
const view = new MapView();
|
||||
@@ -176,6 +179,21 @@ function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Applies a theme to both halves of the app: the Pixi map and the surrounding page chrome. */
|
||||
function applyTheme(name: ThemeName): void {
|
||||
view.setTheme(name);
|
||||
document.documentElement.dataset.theme = name;
|
||||
elements.themeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
|
||||
localStorage.setItem(THEME_KEY, name);
|
||||
}
|
||||
|
||||
function readStoredTheme(): ThemeName {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored === 'day' || stored === 'night') return stored;
|
||||
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day';
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
elements.size.addEventListener('input', () => {
|
||||
elements.sizeValue.textContent = `${elements.size.value} km`;
|
||||
@@ -184,8 +202,13 @@ async function start(): Promise<void> {
|
||||
void generate(event);
|
||||
});
|
||||
|
||||
elements.themeToggle.addEventListener('click', () => {
|
||||
applyTheme(view.themeName === 'day' ? 'night' : 'day');
|
||||
});
|
||||
|
||||
view.onStatusChange = renderHud;
|
||||
await view.init(elements.stage);
|
||||
applyTheme(readStoredTheme());
|
||||
|
||||
try {
|
||||
const worlds = await refreshWorldList();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Camera, rectsIntersect, type Viewport } from './camera';
|
||||
|
||||
const viewport: Viewport = { width: 1000, height: 600 };
|
||||
|
||||
describe('Camera', () => {
|
||||
it('puts its own centre in the middle of the viewport', () => {
|
||||
const camera = new Camera();
|
||||
camera.x = 250;
|
||||
camera.y = -100;
|
||||
camera.zoom = 2;
|
||||
|
||||
const screen = camera.worldToScreen(250, -100, viewport);
|
||||
|
||||
expect(screen.x).toBeCloseTo(500, 6);
|
||||
expect(screen.y).toBeCloseTo(300, 6);
|
||||
});
|
||||
|
||||
it('flips the vertical axis, since world north is screen up', () => {
|
||||
const camera = new Camera();
|
||||
camera.zoom = 1;
|
||||
|
||||
expect(camera.worldToScreen(0, 100, viewport).y).toBeCloseTo(200, 6);
|
||||
expect(camera.worldToScreen(0, -100, viewport).y).toBeCloseTo(400, 6);
|
||||
});
|
||||
|
||||
it('round-trips between world and screen', () => {
|
||||
const camera = new Camera();
|
||||
camera.x = -1234;
|
||||
camera.y = 567;
|
||||
camera.zoom = 0.37;
|
||||
|
||||
const screen = camera.worldToScreen(890, -430, viewport);
|
||||
const world = camera.screenToWorld(screen.x, screen.y, viewport);
|
||||
|
||||
expect(world.x).toBeCloseTo(890, 4);
|
||||
expect(world.y).toBeCloseTo(-430, 4);
|
||||
});
|
||||
|
||||
it('agrees with the container offset it hands to the renderer', () => {
|
||||
const camera = new Camera();
|
||||
camera.x = 120;
|
||||
camera.y = -75;
|
||||
camera.zoom = 1.7;
|
||||
|
||||
const position = camera.containerPosition(viewport);
|
||||
const direct = camera.worldToScreen(400, 250, viewport);
|
||||
|
||||
// This is exactly what the scaled container does: offset, then scale with Y negated.
|
||||
expect(position.x + 400 * camera.zoom).toBeCloseTo(direct.x, 4);
|
||||
expect(position.y - 250 * camera.zoom).toBeCloseTo(direct.y, 4);
|
||||
});
|
||||
|
||||
it('keeps the point under the cursor still while zooming', () => {
|
||||
const camera = new Camera();
|
||||
camera.zoom = 0.5;
|
||||
|
||||
const anchorScreen = { x: 820, y: 140 };
|
||||
const before = camera.screenToWorld(anchorScreen.x, anchorScreen.y, viewport);
|
||||
camera.zoomAt(1.9, anchorScreen.x, anchorScreen.y, viewport);
|
||||
const after = camera.screenToWorld(anchorScreen.x, anchorScreen.y, viewport);
|
||||
|
||||
expect(after.x).toBeCloseTo(before.x, 4);
|
||||
expect(after.y).toBeCloseTo(before.y, 4);
|
||||
});
|
||||
|
||||
it('will not zoom past its limits', () => {
|
||||
const camera = new Camera();
|
||||
camera.minZoom = 0.1;
|
||||
camera.maxZoom = 4;
|
||||
camera.zoom = 1;
|
||||
|
||||
camera.zoomAt(1000, 500, 300, viewport);
|
||||
expect(camera.zoom).toBe(4);
|
||||
|
||||
camera.zoomAt(0.0001, 500, 300, viewport);
|
||||
expect(camera.zoom).toBe(0.1);
|
||||
});
|
||||
|
||||
it('pans by the dragged distance, in world metres', () => {
|
||||
const camera = new Camera();
|
||||
camera.zoom = 2;
|
||||
|
||||
camera.translateBy(100, 60);
|
||||
|
||||
// Dragging right moves the map right, so the camera moves left; screen down is world south.
|
||||
expect(camera.x).toBeCloseTo(-50, 6);
|
||||
expect(camera.y).toBeCloseTo(30, 6);
|
||||
});
|
||||
|
||||
it('holds the centre inside the world square', () => {
|
||||
const camera = new Camera();
|
||||
camera.x = 99_999;
|
||||
camera.y = -99_999;
|
||||
|
||||
camera.clampToWorld(10_000);
|
||||
|
||||
expect(camera.x).toBe(5000);
|
||||
expect(camera.y).toBe(-5000);
|
||||
});
|
||||
|
||||
it('frames the whole world when fitting, and pins the zoom-out limit to it', () => {
|
||||
const camera = new Camera();
|
||||
camera.fit(10_000, viewport);
|
||||
|
||||
const visible = camera.visibleRect(viewport);
|
||||
|
||||
// The shorter side of the viewport has to cover the world with a little room to spare.
|
||||
expect(visible.maxY - visible.minY).toBeGreaterThan(10_000);
|
||||
expect(camera.x).toBe(0);
|
||||
expect(camera.y).toBe(0);
|
||||
expect(camera.minZoom).toBeLessThan(camera.zoom);
|
||||
});
|
||||
|
||||
it('grows the visible rectangle by the requested padding', () => {
|
||||
const camera = new Camera();
|
||||
camera.zoom = 1;
|
||||
|
||||
const plain = camera.visibleRect(viewport);
|
||||
const padded = camera.visibleRect(viewport, 0.5);
|
||||
|
||||
expect(plain.maxX - plain.minX).toBeCloseTo(1000, 6);
|
||||
expect(padded.maxX - padded.minX).toBeCloseTo(1500, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rectsIntersect', () => {
|
||||
const base = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
|
||||
|
||||
it('detects overlap and separation', () => {
|
||||
expect(rectsIntersect(base, { minX: 5, minY: 5, maxX: 15, maxY: 15 })).toBe(true);
|
||||
expect(rectsIntersect(base, { minX: 11, minY: 0, maxX: 20, maxY: 10 })).toBe(false);
|
||||
expect(rectsIntersect(base, { minX: 0, minY: 11, maxX: 10, maxY: 20 })).toBe(false);
|
||||
});
|
||||
|
||||
it('counts touching edges as overlapping', () => {
|
||||
expect(rectsIntersect(base, { minX: 10, minY: 10, maxX: 20, maxY: 20 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,13 @@ export class Camera {
|
||||
};
|
||||
}
|
||||
|
||||
worldToScreen(worldX: number, worldY: number, viewport: Viewport): { x: number; y: number } {
|
||||
return {
|
||||
x: viewport.width / 2 + (worldX - this.x) * this.zoom,
|
||||
y: viewport.height / 2 - (worldY - this.y) * this.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/** The world rectangle currently on screen, optionally grown by a fraction of its size. */
|
||||
visibleRect(viewport: Viewport, padding = 0): WorldRect {
|
||||
const halfWidth = viewport.width / 2 / this.zoom;
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Container } from 'pixi.js';
|
||||
import { api } from '../api/client';
|
||||
import type { ChunkIndexEntry, MapChunk } from '../api/types';
|
||||
import { rectsIntersect, type WorldRect } from './camera';
|
||||
import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer';
|
||||
|
||||
export interface MapLayers {
|
||||
areas: Container;
|
||||
water: Container;
|
||||
roadCasings: Container;
|
||||
roads: Container;
|
||||
buildings: Container;
|
||||
}
|
||||
import type { MapLayers } from './layers';
|
||||
|
||||
interface ChunkState {
|
||||
entry: ChunkIndexEntry;
|
||||
rect: WorldRect;
|
||||
data?: MapChunk;
|
||||
graphics?: ChunkGraphics;
|
||||
/** The set being replaced, kept on screen underneath until the new one has faded in over it. */
|
||||
outgoing?: ChunkGraphics;
|
||||
/** 0 to 1 while the current set fades in. */
|
||||
fade: number;
|
||||
/** Profile the current graphics were built for; a mismatch means they need redrawing. */
|
||||
drawnWith?: string;
|
||||
request?: AbortController;
|
||||
@@ -31,6 +27,21 @@ const RETAIN_PADDING = 1.0;
|
||||
|
||||
const MAX_CONCURRENT_REQUESTS = 6;
|
||||
|
||||
/** Long enough to read as a dissolve rather than a flicker, short enough not to feel sluggish. */
|
||||
const FADE_DURATION_MS = 220;
|
||||
|
||||
/**
|
||||
* Chunks drawn per frame.
|
||||
*
|
||||
* Building a chunk's geometry is cheap — the whole map costs about ten milliseconds — but the *first* render
|
||||
* after that has to triangulate it and push it to the GPU, and for a whole map that measures over a hundred
|
||||
* milliseconds. A zoom step invalidates every loaded chunk at once, so drawing them all in one tick stalls
|
||||
* the frame and, because the dissolve keeps the outgoing set alive meanwhile, doubles peak memory at exactly
|
||||
* the wrong moment. Metering the work per frame keeps both bounded: the map firms up over a fraction of a
|
||||
* second instead of locking up.
|
||||
*/
|
||||
const DRAWS_PER_FRAME = 3;
|
||||
|
||||
/**
|
||||
* Keeps the visible slice of a world on screen: fetches chunks as the camera reaches them, draws them into
|
||||
* the shared layers, and drops their graphics once they are well out of view. Chunk data itself stays cached,
|
||||
@@ -41,6 +52,7 @@ export class ChunkManager {
|
||||
private worldId: string | null = null;
|
||||
private inFlight = 0;
|
||||
private pending: ChunkState[] = [];
|
||||
private drawQueue: ChunkState[] = [];
|
||||
|
||||
constructor(private readonly layers: MapLayers) {}
|
||||
|
||||
@@ -55,7 +67,7 @@ export class ChunkManager {
|
||||
}
|
||||
|
||||
get isBusy(): boolean {
|
||||
return this.inFlight > 0 || this.pending.length > 0;
|
||||
return this.inFlight > 0 || this.pending.length > 0 || this.drawQueue.length > 0;
|
||||
}
|
||||
|
||||
setWorld(worldId: string, index: ChunkIndexEntry[]): void {
|
||||
@@ -67,6 +79,7 @@ export class ChunkManager {
|
||||
this.states.set(key(entry.x, entry.y), {
|
||||
entry,
|
||||
rect: { minX, minY, maxX, maxY },
|
||||
fade: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -74,18 +87,24 @@ export class ChunkManager {
|
||||
clear(): void {
|
||||
for (const state of this.states.values()) {
|
||||
state.request?.abort();
|
||||
if (state.graphics) {
|
||||
detach(state.graphics);
|
||||
destroyChunkGraphics(state.graphics);
|
||||
}
|
||||
this.discard(state.graphics);
|
||||
this.discard(state.outgoing);
|
||||
}
|
||||
|
||||
this.states.clear();
|
||||
this.pending = [];
|
||||
this.drawQueue = [];
|
||||
this.inFlight = 0;
|
||||
this.worldId = null;
|
||||
}
|
||||
|
||||
/** The loaded chunks touching a rectangle, for anything that needs the data rather than the drawing. */
|
||||
*visibleChunks(rect: WorldRect): Iterable<MapChunk> {
|
||||
for (const state of this.states.values()) {
|
||||
if (state.data && rectsIntersect(state.rect, rect)) yield state.data;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciles what is on screen with what should be, given the camera's current view and zoom. */
|
||||
update(visible: WorldRect, profile: RenderProfile): void {
|
||||
if (!this.worldId) return;
|
||||
@@ -94,6 +113,7 @@ export class ChunkManager {
|
||||
const retain = grow(visible, RETAIN_PADDING);
|
||||
|
||||
this.pending = [];
|
||||
this.drawQueue = [];
|
||||
|
||||
for (const state of this.states.values()) {
|
||||
const wanted = rectsIntersect(state.rect, prefetch);
|
||||
@@ -105,24 +125,73 @@ export class ChunkManager {
|
||||
|
||||
if (!state.data) continue;
|
||||
|
||||
if (wanted) {
|
||||
if (!state.graphics || state.drawnWith !== profile.key) this.draw(state, profile);
|
||||
} else if (state.graphics && !rectsIntersect(state.rect, retain)) {
|
||||
detach(state.graphics);
|
||||
destroyChunkGraphics(state.graphics);
|
||||
if (!wanted) {
|
||||
if (state.graphics && !rectsIntersect(state.rect, retain)) {
|
||||
this.discard(state.graphics);
|
||||
this.discard(state.outgoing);
|
||||
state.graphics = undefined;
|
||||
state.outgoing = undefined;
|
||||
state.drawnWith = undefined;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nearest chunks first: the middle of the screen is what the player is looking at.
|
||||
// Chunks already carrying the right geometry are left alone; everything else joins the draw queue.
|
||||
// Off-screen ones keep whatever detail they have until they scroll in and become worth redrawing.
|
||||
if (!state.graphics || (state.drawnWith !== profile.key && rectsIntersect(state.rect, visible))) {
|
||||
this.drawQueue.push(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Nearest first, for both queues: the middle of the screen is what the player is looking at.
|
||||
const centerX = (visible.minX + visible.maxX) / 2;
|
||||
const centerY = (visible.minY + visible.maxY) / 2;
|
||||
this.pending.sort((a, b) => distanceSquared(a.rect, centerX, centerY) - distanceSquared(b.rect, centerX, centerY));
|
||||
const byDistance = (a: ChunkState, b: ChunkState) =>
|
||||
distanceSquared(a.rect, centerX, centerY) - distanceSquared(b.rect, centerX, centerY);
|
||||
|
||||
this.pending.sort(byDistance);
|
||||
this.drawQueue.sort(byDistance);
|
||||
|
||||
this.pump(profile);
|
||||
}
|
||||
|
||||
/** Draws the next few queued chunks. Called every frame, which is what meters the GPU upload cost. */
|
||||
processDrawQueue(profile: RenderProfile): void {
|
||||
let budget = DRAWS_PER_FRAME;
|
||||
|
||||
while (budget > 0 && this.drawQueue.length > 0) {
|
||||
const state = this.drawQueue.shift()!;
|
||||
|
||||
// The queue is built once per reconciliation, so entries can go stale before their turn comes.
|
||||
if (!state.data || (state.graphics && state.drawnWith === profile.key)) continue;
|
||||
|
||||
this.draw(state, profile);
|
||||
budget--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the dissolve between two levels of detail. The outgoing set stays fully opaque underneath while
|
||||
* the new one fades in on top, so the map never washes out mid-transition — it simply firms up.
|
||||
*/
|
||||
advanceFades(deltaMs: number): void {
|
||||
if (deltaMs <= 0) return;
|
||||
const step = deltaMs / FADE_DURATION_MS;
|
||||
|
||||
for (const state of this.states.values()) {
|
||||
if (state.fade >= 1 || !state.graphics) continue;
|
||||
|
||||
state.fade = Math.min(1, state.fade + step);
|
||||
for (const part of state.graphics) part.graphics.alpha = state.fade;
|
||||
|
||||
if (state.fade >= 1 && state.outgoing) {
|
||||
this.discard(state.outgoing);
|
||||
state.outgoing = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pump(profile: RenderProfile): void {
|
||||
while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) {
|
||||
const state = this.pending.shift()!;
|
||||
@@ -145,7 +214,9 @@ export class ChunkManager {
|
||||
if (this.worldId !== worldId) return;
|
||||
|
||||
state.data = chunk;
|
||||
this.draw(state, profile);
|
||||
|
||||
// Queued rather than drawn here, so a burst of arrivals is metered like any other batch of work.
|
||||
this.drawQueue.push(state);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
state.failed = true;
|
||||
@@ -161,26 +232,28 @@ export class ChunkManager {
|
||||
private draw(state: ChunkState, profile: RenderProfile): void {
|
||||
if (!state.data) return;
|
||||
|
||||
if (state.graphics) {
|
||||
detach(state.graphics);
|
||||
destroyChunkGraphics(state.graphics);
|
||||
}
|
||||
// A redraw arriving mid-dissolve replaces whatever was still fading; only one set may be outgoing.
|
||||
this.discard(state.outgoing);
|
||||
state.outgoing = state.graphics;
|
||||
|
||||
const graphics = renderChunk(state.data, profile);
|
||||
state.graphics = graphics;
|
||||
state.drawnWith = profile.key;
|
||||
|
||||
this.layers.areas.addChild(graphics.areas);
|
||||
this.layers.water.addChild(graphics.water);
|
||||
this.layers.roadCasings.addChild(graphics.roadCasings);
|
||||
this.layers.roads.addChild(graphics.roads);
|
||||
this.layers.buildings.addChild(graphics.buildings);
|
||||
// A chunk appearing for the first time has nothing to dissolve from, so it simply appears.
|
||||
state.fade = state.outgoing ? 0 : 1;
|
||||
|
||||
for (const part of graphics) {
|
||||
part.graphics.alpha = state.fade;
|
||||
this.layers[part.layer].addChild(part.graphics);
|
||||
}
|
||||
}
|
||||
|
||||
function detach(graphics: ChunkGraphics): void {
|
||||
for (const layer of Object.values(graphics)) {
|
||||
layer.removeFromParent();
|
||||
private discard(graphics: ChunkGraphics | undefined): void {
|
||||
if (!graphics) return;
|
||||
|
||||
for (const part of graphics) part.graphics.removeFromParent();
|
||||
destroyChunkGraphics(graphics);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { AreaFeature, BuildingFeature, FlatPoints, MapChunk, RoadFeature, WaterFeature } from '../api/types';
|
||||
import {
|
||||
AREA_COLORS,
|
||||
BUILDING_EDGE_COLOR,
|
||||
ROAD_CASING_COLORS,
|
||||
ROAD_COLORS,
|
||||
ROAD_MIN_DETAIL,
|
||||
ROAD_RANK,
|
||||
WATER_COLOR,
|
||||
WATER_EDGE_COLOR,
|
||||
WATER_LINE_COLORS,
|
||||
buildingColor,
|
||||
} from './style';
|
||||
RoadFlags,
|
||||
type AreaFeature,
|
||||
type BuildingFeature,
|
||||
type FlatPoints,
|
||||
type MapChunk,
|
||||
type RoadFeature,
|
||||
type WaterFeature,
|
||||
} from '../api/types';
|
||||
import {
|
||||
boundingArea,
|
||||
dashPolyline,
|
||||
pointsAlong,
|
||||
polylineLength,
|
||||
smoothPolyline,
|
||||
translate,
|
||||
} from './geometry';
|
||||
import { areaLayer, roadBand, roadLayers, ROAD_BANDS, type LayerId, type RoadBand } from './layers';
|
||||
import { isDashed, isRailway, ROAD_MIN_DETAIL, ROAD_RANK } from './style';
|
||||
import { roofColor, wallColor, type Theme } from './theme';
|
||||
|
||||
/** 0 = whole town in view, 1 = neighbourhood, 2 = street level. */
|
||||
export type DetailLevel = 0 | 1 | 2;
|
||||
|
||||
export interface RenderProfile {
|
||||
/** Changes exactly when the geometry needs redrawing, and not otherwise. */
|
||||
/** Changes exactly when the geometry needs redrawing, and not otherwise — theme switches included. */
|
||||
key: string;
|
||||
detail: DetailLevel;
|
||||
/** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */
|
||||
minStrokeMeters: number;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/** Strokes thinner than this fade into the background. */
|
||||
@@ -29,37 +37,64 @@ const MIN_STROKE_PIXELS = 1.3;
|
||||
|
||||
const MIN_BUILDING_AREA: Record<DetailLevel, number> = { 0: 150, 1: 30, 2: 0 };
|
||||
|
||||
export function profileForZoom(zoom: number): RenderProfile {
|
||||
/** How far a roof floats above its footprint, per metre of height, and where that stops growing. */
|
||||
const WALL_RISE_PER_METRE = 0.16;
|
||||
const MAX_WALL_RISE_METRES = 5;
|
||||
|
||||
/** Spacing of one-way chevrons, and the shortest road worth marking. */
|
||||
const ARROW_SPACING_METRES = 45;
|
||||
const MIN_ARROW_ROAD_LENGTH = 30;
|
||||
|
||||
export function profileForZoom(zoom: number, theme: Theme): RenderProfile {
|
||||
const detail: DetailLevel = zoom >= 0.35 ? 2 : zoom >= 0.12 ? 1 : 0;
|
||||
|
||||
// Quantising to powers of two means a slow zoom redraws a handful of times rather than every frame.
|
||||
const step = Math.floor(Math.log2(zoom));
|
||||
const minStrokeMeters = MIN_STROKE_PIXELS / 2 ** step;
|
||||
|
||||
return { key: `${detail}@${step}`, detail, minStrokeMeters };
|
||||
return { key: `${theme.name}:${detail}@${step}`, detail, minStrokeMeters, theme };
|
||||
}
|
||||
|
||||
export interface ChunkGraphics {
|
||||
areas: Graphics;
|
||||
water: Graphics;
|
||||
roadCasings: Graphics;
|
||||
roads: Graphics;
|
||||
buildings: Graphics;
|
||||
export interface ChunkPart {
|
||||
layer: LayerId;
|
||||
graphics: Graphics;
|
||||
}
|
||||
|
||||
export type ChunkGraphics = ChunkPart[];
|
||||
|
||||
export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics {
|
||||
return {
|
||||
areas: renderAreas(chunk.areas),
|
||||
water: renderWater(chunk.water, profile),
|
||||
roadCasings: renderRoads(chunk.roads, profile, true),
|
||||
roads: renderRoads(chunk.roads, profile, false),
|
||||
buildings: renderBuildings(chunk.buildings, profile),
|
||||
};
|
||||
const painter = new Painter();
|
||||
|
||||
renderAreas(painter, chunk.areas, profile);
|
||||
renderWater(painter, chunk.water, profile);
|
||||
renderRoads(painter, chunk.roads, profile);
|
||||
renderBuildings(painter, chunk.buildings, profile);
|
||||
|
||||
return painter.parts();
|
||||
}
|
||||
|
||||
export function destroyChunkGraphics(graphics: ChunkGraphics): void {
|
||||
for (const layer of Object.values(graphics)) {
|
||||
layer.destroy();
|
||||
for (const part of graphics) {
|
||||
part.graphics.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands out one Graphics per layer, creating them only for layers that end up with something in them. */
|
||||
class Painter {
|
||||
private readonly byLayer = new Map<LayerId, Graphics>();
|
||||
|
||||
on(layer: LayerId): Graphics {
|
||||
let graphics = this.byLayer.get(layer);
|
||||
if (!graphics) {
|
||||
graphics = new Graphics();
|
||||
this.byLayer.set(layer, graphics);
|
||||
}
|
||||
|
||||
return graphics;
|
||||
}
|
||||
|
||||
parts(): ChunkPart[] {
|
||||
return [...this.byLayer].map(([layer, graphics]) => ({ layer, graphics }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,94 +108,297 @@ interface EdgeStyle {
|
||||
color: number;
|
||||
}
|
||||
|
||||
function renderAreas(areas: AreaFeature[]): Graphics {
|
||||
const g = new Graphics();
|
||||
|
||||
// Big landuse blocks first, so the parks and pitches sitting inside them stay visible.
|
||||
function renderAreas(painter: Painter, areas: AreaFeature[], profile: RenderProfile): void {
|
||||
// Big blocks first, so the parks and pitches sitting inside them stay visible.
|
||||
const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline));
|
||||
|
||||
forEachRun(
|
||||
sorted,
|
||||
(area) => AREA_COLORS[area.kind] ?? AREA_COLORS.unknown,
|
||||
(run, color) => fillRings(g, run, color),
|
||||
(area) => `${areaLayer(area.kind)}|${area.kind}`,
|
||||
(run) => {
|
||||
const kind = run[0]!.kind;
|
||||
const color = profile.theme.areas[kind] ?? profile.theme.areas.unknown;
|
||||
fillRings(painter.on(areaLayer(kind)), run, color);
|
||||
},
|
||||
);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderWater(water: WaterFeature[], profile: RenderProfile): Graphics {
|
||||
const g = new Graphics();
|
||||
function renderWater(painter: Painter, water: WaterFeature[], profile: RenderProfile): void {
|
||||
if (water.length === 0) return;
|
||||
|
||||
const { theme } = profile;
|
||||
const surface = painter.on('water');
|
||||
|
||||
const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline);
|
||||
fillRings(g, bodies, WATER_COLOR, { width: profile.minStrokeMeters, color: WATER_EDGE_COLOR });
|
||||
fillRings(surface, bodies, theme.water, { width: profile.minStrokeMeters, color: theme.waterEdge });
|
||||
|
||||
for (const stream of water) {
|
||||
if (!stream.path) continue;
|
||||
|
||||
g.poly(stream.path, false).stroke({
|
||||
// Watercourses meander, and drawing them straight between OSM nodes is where that shows worst.
|
||||
surface.poly(curve(stream.path, profile), false).stroke({
|
||||
width: Math.max(stream.width ?? 2, profile.minStrokeMeters),
|
||||
color: WATER_LINE_COLORS[stream.kind] ?? WATER_COLOR,
|
||||
color: theme.waterLines[stream.kind] ?? theme.water,
|
||||
cap: 'round',
|
||||
join: 'round',
|
||||
});
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics {
|
||||
const g = new Graphics();
|
||||
function renderRoads(painter: Painter, roads: RoadFeature[], profile: RenderProfile): void {
|
||||
const visible = roads.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail);
|
||||
if (visible.length === 0) return;
|
||||
|
||||
const visible = roads
|
||||
.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail)
|
||||
.sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width);
|
||||
const byBand = new Map<RoadBand, RoadFeature[]>();
|
||||
for (const road of visible) {
|
||||
const band = roadBand(road);
|
||||
const group = byBand.get(band);
|
||||
if (group) group.push(road);
|
||||
else byBand.set(band, [road]);
|
||||
}
|
||||
|
||||
// Bands are emitted in a fixed order so a chunk's own graphics match the global layer order.
|
||||
for (const band of ROAD_BANDS) {
|
||||
const group = byBand.get(band);
|
||||
if (!group) continue;
|
||||
|
||||
const { casing, fill } = roadLayers(band);
|
||||
const alpha = band === 'tunnel' ? profile.theme.tunnelAlpha : 1;
|
||||
|
||||
strokeRoads(painter.on(casing), group, profile, true, alpha);
|
||||
strokeRoads(painter.on(fill), group, profile, false, alpha);
|
||||
}
|
||||
|
||||
if (profile.detail === 2) renderOnewayArrows(painter, visible, profile);
|
||||
}
|
||||
|
||||
function strokeRoads(
|
||||
g: Graphics,
|
||||
roads: RoadFeature[],
|
||||
profile: RenderProfile,
|
||||
casing: boolean,
|
||||
alpha: number,
|
||||
): void {
|
||||
const { theme } = profile;
|
||||
|
||||
// Sorted by class then width, so a run shares both and can be stroked in a single call.
|
||||
const sorted = [...roads].sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width);
|
||||
|
||||
// The casing is a slightly wider stroke drawn underneath; it is what gives roads their outline.
|
||||
const casingMargin = Math.max(1.4, profile.minStrokeMeters * 0.7);
|
||||
|
||||
// Sorted by class then width, so a run shares both and can be stroked in a single call.
|
||||
forEachRun(
|
||||
visible,
|
||||
sorted,
|
||||
(road) => `${road.class}:${road.width}`,
|
||||
(run) => {
|
||||
const first = run[0]!;
|
||||
for (const road of run) g.poly(road.path, false);
|
||||
|
||||
const width = Math.max(first.width, profile.minStrokeMeters);
|
||||
|
||||
if (isRailway(first.class)) {
|
||||
strokeRailway(g, run, width, casing, alpha, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!casing && isDashed(first.class) && profile.detail === 2) {
|
||||
strokeDashed(g, run, width, alpha, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const road of run) g.poly(curve(road.path, profile), false);
|
||||
|
||||
g.stroke({
|
||||
width: casing ? width + casingMargin : width,
|
||||
color: casing ? ROAD_CASING_COLORS[first.class] : ROAD_COLORS[first.class],
|
||||
color: casing ? theme.roadCasing[first.class] : theme.roadFill[first.class],
|
||||
alpha,
|
||||
cap: 'round',
|
||||
join: 'round',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics {
|
||||
const g = new Graphics();
|
||||
/** Footways, paths, steps and cycleways: a dashed line so they never read as a pale street. */
|
||||
function strokeDashed(
|
||||
g: Graphics,
|
||||
roads: RoadFeature[],
|
||||
width: number,
|
||||
alpha: number,
|
||||
profile: RenderProfile,
|
||||
): void {
|
||||
const dash = Math.max(width * 2.4, profile.minStrokeMeters * 3);
|
||||
const gap = dash * 0.7;
|
||||
|
||||
for (const road of roads) {
|
||||
for (const piece of dashPolyline(curve(road.path, profile), dash, gap)) {
|
||||
g.poly(piece, false);
|
||||
}
|
||||
}
|
||||
|
||||
g.stroke({
|
||||
width,
|
||||
color: profile.theme.roadFill[roads[0]!.class],
|
||||
alpha,
|
||||
cap: 'butt',
|
||||
join: 'round',
|
||||
});
|
||||
}
|
||||
|
||||
/** A solid dark bed with light sleepers dashed over it — the conventional way rail reads on a map. */
|
||||
function strokeRailway(
|
||||
g: Graphics,
|
||||
roads: RoadFeature[],
|
||||
width: number,
|
||||
casing: boolean,
|
||||
alpha: number,
|
||||
profile: RenderProfile,
|
||||
): void {
|
||||
if (casing) {
|
||||
for (const road of roads) g.poly(curve(road.path, profile), false);
|
||||
g.stroke({ width, color: profile.theme.railBed, alpha, cap: 'butt', join: 'round' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sleepers are only legible once a dash is a few pixels long; below that the bed alone is clearer.
|
||||
if (profile.detail < 1) return;
|
||||
|
||||
const dash = Math.max(width * 2.2, profile.minStrokeMeters * 4);
|
||||
for (const road of roads) {
|
||||
for (const piece of dashPolyline(curve(road.path, profile), dash, dash)) {
|
||||
g.poly(piece, false);
|
||||
}
|
||||
}
|
||||
|
||||
g.stroke({ width: width * 0.62, color: profile.theme.railSleeper, alpha, cap: 'butt' });
|
||||
}
|
||||
|
||||
/** Chevrons pointing the way traffic runs, spaced along every one-way street. */
|
||||
function renderOnewayArrows(painter: Painter, roads: RoadFeature[], profile: RenderProfile): void {
|
||||
const oneways = roads.filter((road) => ((road.flags ?? 0) & RoadFlags.oneway) !== 0);
|
||||
if (oneways.length === 0) return;
|
||||
|
||||
const g = painter.on('roadArrows');
|
||||
let drew = false;
|
||||
|
||||
for (const road of oneways) {
|
||||
const path = curve(road.path, profile);
|
||||
if (polylineLength(path) < MIN_ARROW_ROAD_LENGTH) continue;
|
||||
|
||||
const size = Math.max(road.width * 0.35, profile.minStrokeMeters * 1.5);
|
||||
|
||||
for (const marker of pointsAlong(path, ARROW_SPACING_METRES, ARROW_SPACING_METRES / 2)) {
|
||||
const sin = Math.sin(marker.angle);
|
||||
const cos = Math.cos(marker.angle);
|
||||
|
||||
// A chevron: two arms swept back from the tip, rotated onto the direction of travel.
|
||||
const tipX = marker.x + cos * size;
|
||||
const tipY = marker.y + sin * size;
|
||||
|
||||
for (const side of [1, -1]) {
|
||||
const backX = -cos * size + side * -sin * size * 0.8;
|
||||
const backY = -sin * size + side * cos * size * 0.8;
|
||||
g.poly([tipX, tipY, tipX + backX, tipY + backY], false);
|
||||
}
|
||||
|
||||
drew = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (drew) {
|
||||
g.stroke({
|
||||
width: Math.max(profile.minStrokeMeters * 0.7, 0.5),
|
||||
color: profile.theme.arrow,
|
||||
alpha: profile.theme.arrowAlpha,
|
||||
cap: 'round',
|
||||
join: 'round',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderBuildings(painter: Painter, buildings: BuildingFeature[], profile: RenderProfile): void {
|
||||
const { theme } = profile;
|
||||
const minArea = MIN_BUILDING_AREA[profile.detail];
|
||||
const visible = minArea > 0 ? buildings.filter((b) => boundingArea(b.outline) >= minArea) : buildings;
|
||||
if (visible.length === 0) return;
|
||||
|
||||
// Below street level the rise would be a fraction of a pixel, so the whole extrusion pass is skipped.
|
||||
const extrude = profile.detail === 2;
|
||||
const roofs = painter.on('buildings');
|
||||
const edge: EdgeStyle | undefined =
|
||||
profile.detail === 2 ? { width: profile.minStrokeMeters * 0.8, color: BUILDING_EDGE_COLOR } : undefined;
|
||||
extrude ? { width: profile.minStrokeMeters * 0.8, color: theme.buildingEdge } : undefined;
|
||||
|
||||
// Footprints never overlap, so they can be regrouped by colour without disturbing the picture.
|
||||
const byColor = new Map<number, BuildingFeature[]>();
|
||||
for (const building of buildings) {
|
||||
if (minArea > 0 && boundingArea(building.outline) < minArea) continue;
|
||||
|
||||
const color = buildingColor(building.kind, building.height);
|
||||
for (const building of visible) {
|
||||
const color = roofColor(theme, building.kind, building.height);
|
||||
const group = byColor.get(color);
|
||||
if (group) group.push(building);
|
||||
else byColor.set(color, [building]);
|
||||
}
|
||||
|
||||
if (extrude) {
|
||||
const walls = painter.on('buildingWalls');
|
||||
for (const [color, group] of byColor) {
|
||||
fillRings(g, group, color, edge);
|
||||
renderWalls(walls, group, wallColor(theme, color));
|
||||
}
|
||||
}
|
||||
|
||||
return g;
|
||||
for (const [color, group] of byColor) {
|
||||
const lifted = extrude
|
||||
? group.map((building) => liftRoof(building))
|
||||
: group;
|
||||
|
||||
fillRings(roofs, lifted, color, edge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fakes height by drawing a quad down from every footprint edge to the lifted roof. Quads that face away are
|
||||
* hidden behind the ones that face the viewer, and since they all share a colour the union is exactly the
|
||||
* silhouette — no visibility test needed.
|
||||
*/
|
||||
function renderWalls(g: Graphics, buildings: BuildingFeature[], color: number): void {
|
||||
let drew = false;
|
||||
|
||||
for (const building of buildings) {
|
||||
const rise = riseOf(building);
|
||||
if (rise < 0.3) continue;
|
||||
|
||||
const outline = building.outline;
|
||||
const count = outline.length / 2;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const j = (i + 1) % count;
|
||||
const ax = outline[i * 2]!;
|
||||
const ay = outline[i * 2 + 1]!;
|
||||
const bx = outline[j * 2]!;
|
||||
const by = outline[j * 2 + 1]!;
|
||||
|
||||
g.poly([ax, ay, bx, by, bx, by + rise, ax, ay + rise]);
|
||||
drew = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (drew) g.fill({ color });
|
||||
}
|
||||
|
||||
function liftRoof(building: BuildingFeature): Ring {
|
||||
const rise = riseOf(building);
|
||||
if (rise < 0.3) return building;
|
||||
|
||||
return {
|
||||
outline: translate(building.outline, 0, rise),
|
||||
...(building.holes ? { holes: building.holes.map((hole) => translate(hole, 0, rise)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The roof rises northward on screen, so what the viewer sees are the south-facing walls. */
|
||||
function riseOf(building: BuildingFeature): number {
|
||||
return Math.min(building.height * WALL_RISE_PER_METRE, MAX_WALL_RISE_METRES);
|
||||
}
|
||||
|
||||
/** Smooths gentle bends, but only at street level where the extra vertices actually show. */
|
||||
function curve(points: FlatPoints, profile: RenderProfile): FlatPoints {
|
||||
return profile.detail === 2 ? smoothPolyline(points) : points;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,27 +433,6 @@ function fillRings(g: Graphics, rings: Ring[], color: number, edge?: EdgeStyle):
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */
|
||||
function boundingArea(points: FlatPoints): number {
|
||||
if (points.length < 6) return 0;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const x = points[i]!;
|
||||
const y = points[i + 1]!;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
return (maxX - minX) * (maxY - minY);
|
||||
}
|
||||
|
||||
/** Walks a sorted list, handing over each maximal run of items that share a key. */
|
||||
function forEachRun<T, K>(items: T[], keyOf: (item: T) => K, draw: (run: T[], key: K) => void): void {
|
||||
let index = 0;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
boundingArea,
|
||||
dashPolyline,
|
||||
pointsAlong,
|
||||
polygonCentroid,
|
||||
polylineAnchor,
|
||||
polylineLength,
|
||||
smoothPolyline,
|
||||
translate,
|
||||
} from './geometry';
|
||||
|
||||
const line = (...points: number[]) => points;
|
||||
|
||||
describe('dashPolyline', () => {
|
||||
it('splits a straight line into alternating dashes', () => {
|
||||
const pieces = dashPolyline(line(0, 0, 10, 0), 2, 2);
|
||||
|
||||
// 2 on, 2 off, repeating over 10 m: dashes at 0-2, 4-6, 8-10.
|
||||
expect(pieces).toEqual([
|
||||
[0, 0, 2, 0],
|
||||
[4, 0, 6, 0],
|
||||
[8, 0, 10, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the pattern across a corner instead of restarting it', () => {
|
||||
const pieces = dashPolyline(line(0, 0, 3, 0, 3, 3), 2, 2);
|
||||
|
||||
// The first dash ends at 2 m, the gap runs to 4 m — one metre of which is past the corner.
|
||||
expect(pieces[0]).toEqual([0, 0, 2, 0]);
|
||||
expect(pieces[1]![0]).toBeCloseTo(3, 6);
|
||||
expect(pieces[1]![1]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('keeps every piece on the line', () => {
|
||||
const pieces = dashPolyline(line(0, 0, 100, 0), 3, 2);
|
||||
|
||||
for (const piece of pieces) {
|
||||
expect(piece[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(piece[2]).toBeLessThanOrEqual(100.000001);
|
||||
expect(piece[1]).toBeCloseTo(0, 6);
|
||||
expect(piece[3]).toBeCloseTo(0, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores zero-length segments', () => {
|
||||
const pieces = dashPolyline(line(0, 0, 0, 0, 10, 0), 2, 2);
|
||||
|
||||
expect(pieces.length).toBeGreaterThan(0);
|
||||
expect(pieces[0]).toEqual([0, 0, 2, 0]);
|
||||
});
|
||||
|
||||
it('returns nothing for a degenerate pattern', () => {
|
||||
expect(dashPolyline(line(0, 0, 100, 0), 0, 5)).toEqual([]);
|
||||
expect(dashPolyline(line(0, 0, 100, 0), 5, 0)).toEqual([]);
|
||||
expect(dashPolyline(line(0, 0, 100, 0), Number.NaN, 5)).toEqual([]);
|
||||
expect(dashPolyline(line(0, 0), 2, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a dash so small it would flood the output', () => {
|
||||
// Five kilometres at a millimetre a dash is millions of pieces; drawing nothing beats exhausting memory.
|
||||
expect(dashPolyline(line(0, 0, 5000, 0), 0.001, 0.0007)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this module was rewritten for. Recovering the pattern phase from `distance % period` can
|
||||
* land a hair below the end of a dash; the resulting step is far too small to change a float position, so
|
||||
* the loop stops advancing and pushes pieces forever until the tab dies.
|
||||
*/
|
||||
it('always terminates, whatever the dash lands on', () => {
|
||||
const cases: Array<[number, number, number]> = [
|
||||
[1000, 3.9, 2.73],
|
||||
[512, 1.5, 1.05],
|
||||
[0.5, 100, 70],
|
||||
[843.7591, 2.6, 1.82],
|
||||
[1e-9, 1, 1],
|
||||
];
|
||||
|
||||
for (const [length, dash, gap] of cases) {
|
||||
const pieces = dashPolyline(line(0, 0, length, 0), dash, gap);
|
||||
expect(pieces.length).toBeLessThanOrEqual(Math.ceil(length / (dash + gap)) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('terminates across a sweep of lengths and dash sizes', () => {
|
||||
// Deterministic pseudo-random sweep: the failure only showed up on particular float combinations.
|
||||
let seed = 12345;
|
||||
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const length = 1 + next() * 900;
|
||||
const dash = 0.05 + next() * 12;
|
||||
const gap = dash * (0.3 + next());
|
||||
|
||||
const pieces = dashPolyline(line(0, 0, length * 0.3, length * 0.4, length, 0), dash, gap);
|
||||
expect(pieces.length).toBeLessThanOrEqual(Math.ceil((length * 2) / (dash + gap)) + 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('smoothPolyline', () => {
|
||||
it('leaves both ends exactly where they were', () => {
|
||||
// Pieces of a road split at a chunk boundary are smoothed separately and must still meet.
|
||||
const points = line(0, 0, 10, 1, 20, 0, 30, 2);
|
||||
const smoothed = smoothPolyline(points);
|
||||
|
||||
expect(smoothed[0]).toBe(0);
|
||||
expect(smoothed[1]).toBe(0);
|
||||
expect(smoothed[smoothed.length - 2]).toBe(30);
|
||||
expect(smoothed[smoothed.length - 1]).toBe(2);
|
||||
});
|
||||
|
||||
it('rounds a gentle bend by adding vertices', () => {
|
||||
const points = line(0, 0, 10, 0, 20, 2);
|
||||
const smoothed = smoothPolyline(points);
|
||||
|
||||
expect(smoothed.length).toBeGreaterThan(points.length);
|
||||
});
|
||||
|
||||
it('leaves a right angle alone', () => {
|
||||
// A gridded town is full of genuine right angles; rounding those would be wrong, not pretty.
|
||||
const points = line(0, 0, 10, 0, 10, 10);
|
||||
const smoothed = smoothPolyline(points);
|
||||
|
||||
expect(smoothed).toEqual(points);
|
||||
});
|
||||
|
||||
it('passes short lines straight through', () => {
|
||||
const points = line(0, 0, 5, 5);
|
||||
expect(smoothPolyline(points)).toBe(points);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointsAlong', () => {
|
||||
it('places markers at the requested spacing', () => {
|
||||
const placed = pointsAlong(line(0, 0, 100, 0), 25);
|
||||
|
||||
expect(placed.map((p) => p.x)).toEqual([0, 25, 50, 75, 100]);
|
||||
expect(placed.every((p) => p.angle === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('starts at the offset', () => {
|
||||
const placed = pointsAlong(line(0, 0, 100, 0), 40, 10);
|
||||
expect(placed.map((p) => p.x)).toEqual([10, 50, 90]);
|
||||
});
|
||||
|
||||
it('reports the direction of travel', () => {
|
||||
const placed = pointsAlong(line(0, 0, 0, 10), 5);
|
||||
expect(placed[0]!.angle).toBeCloseTo(Math.PI / 2, 6);
|
||||
});
|
||||
|
||||
it('returns nothing for a degenerate request', () => {
|
||||
expect(pointsAlong(line(0, 0, 10, 0), 0)).toEqual([]);
|
||||
expect(pointsAlong(line(0, 0), 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('polylineLength and polylineAnchor', () => {
|
||||
it('measures a bent line', () => {
|
||||
expect(polylineLength(line(0, 0, 3, 4, 3, 14))).toBeCloseTo(15, 6);
|
||||
});
|
||||
|
||||
it('anchors at the halfway point by arc length, not by vertex count', () => {
|
||||
// Most vertices are crowded at the start, so a vertex average would sit well left of centre.
|
||||
const anchor = polylineAnchor(line(0, 0, 1, 0, 2, 0, 3, 0, 103, 0));
|
||||
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor!.x).toBeCloseTo(51.5, 6);
|
||||
expect(anchor!.angle).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('has no anchor for a line of zero length', () => {
|
||||
expect(polylineAnchor(line(5, 5, 5, 5))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('polygonCentroid', () => {
|
||||
it('finds the centre of a square', () => {
|
||||
const centroid = polygonCentroid(line(0, 0, 10, 0, 10, 10, 0, 10));
|
||||
|
||||
expect(centroid!.x).toBeCloseTo(5, 6);
|
||||
expect(centroid!.y).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
it('weights by area rather than by vertex count', () => {
|
||||
// An L of a 30x10 arm and a 10x20 arm. By area the centroid is (11, 11); the plain average of the six
|
||||
// vertices would be (13.3, 11.7), which is outside the shape's waist.
|
||||
const centroid = polygonCentroid(line(0, 0, 30, 0, 30, 10, 10, 10, 10, 30, 0, 30));
|
||||
|
||||
expect(centroid!.x).toBeCloseTo(11, 6);
|
||||
expect(centroid!.y).toBeCloseTo(11, 6);
|
||||
});
|
||||
|
||||
it('falls back to the vertex average when the ring has no area', () => {
|
||||
const centroid = polygonCentroid(line(0, 0, 10, 0, 20, 0));
|
||||
|
||||
expect(centroid!.x).toBeCloseTo(10, 6);
|
||||
expect(centroid!.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('rejects anything that is not a ring', () => {
|
||||
expect(polygonCentroid(line(0, 0, 1, 1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('translate and boundingArea', () => {
|
||||
it('shifts every point', () => {
|
||||
expect(translate(line(0, 0, 2, 3), 1, -1)).toEqual([1, -1, 3, 2]);
|
||||
});
|
||||
|
||||
it('measures the bounding box', () => {
|
||||
expect(boundingArea(line(0, 0, 4, 0, 4, 5))).toBe(20);
|
||||
expect(boundingArea(line(0, 0, 1, 1))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { FlatPoints } from '../api/types';
|
||||
|
||||
/** Everything here works on flat `[x0, y0, x1, y1, …]` arrays, the format geometry arrives in. */
|
||||
|
||||
/**
|
||||
* Ceiling on dashes per polyline. A chunk-sized road at street zoom needs a few hundred; anything asking for
|
||||
* thousands is a bad dash length, and drawing nothing is far better than exhausting memory.
|
||||
*/
|
||||
const MAX_DASHES_PER_LINE = 4000;
|
||||
|
||||
/**
|
||||
* Cuts a polyline into dash-length pieces, carrying the pattern across corners so the dashes stay evenly
|
||||
* spaced along the whole line.
|
||||
*
|
||||
* The cursor is carried explicitly rather than recovered from `distance % period` each step. That modulo can
|
||||
* land a hair below the end of a dash, leaving a step of about 1e-16 — and adding that to a position of a few
|
||||
* hundred metres does not change a float64 at all, so the loop spins forever and the output array grows until
|
||||
* the tab runs out of memory. Here every step is either a whole remaining dash or gap, or the rest of the
|
||||
* segment, and the loop leaves the segment the moment it reaches the end.
|
||||
*/
|
||||
export function dashPolyline(points: FlatPoints, dash: number, gap: number): FlatPoints[] {
|
||||
const pieces: FlatPoints[] = [];
|
||||
if (!(dash > 0) || !(gap > 0) || points.length < 4) return pieces;
|
||||
|
||||
// A dash far too small for the line would produce millions of pieces. No caller asks for that today, but
|
||||
// the cost of getting it wrong is the whole tab, so the ceiling is enforced here rather than trusted.
|
||||
if (polylineLength(points) / (dash + gap) > MAX_DASHES_PER_LINE) return pieces;
|
||||
|
||||
let inDash = true;
|
||||
let remaining = dash;
|
||||
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
const x0 = points[i]!;
|
||||
const y0 = points[i + 1]!;
|
||||
const x1 = points[i + 2]!;
|
||||
const y1 = points[i + 3]!;
|
||||
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (!(length > 0)) continue;
|
||||
|
||||
let position = 0;
|
||||
while (position < length) {
|
||||
const step = Math.min(remaining, length - position);
|
||||
const to = position + step;
|
||||
|
||||
if (inDash) {
|
||||
const from = position / length;
|
||||
const until = to / length;
|
||||
pieces.push([
|
||||
x0 + (x1 - x0) * from,
|
||||
y0 + (y1 - y0) * from,
|
||||
x0 + (x1 - x0) * until,
|
||||
y0 + (y1 - y0) * until,
|
||||
]);
|
||||
}
|
||||
|
||||
remaining -= step;
|
||||
if (remaining <= 0) {
|
||||
inDash = !inDash;
|
||||
remaining = inDash ? dash : gap;
|
||||
}
|
||||
|
||||
// Either the segment is spent or the cursor moved; both end the loop rather than risk standing still.
|
||||
if (to >= length || to <= position) break;
|
||||
position = to;
|
||||
}
|
||||
}
|
||||
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds off gentle bends with Chaikin corner cutting, which converges on a quadratic B-spline while keeping
|
||||
* both ends pinned — so pieces of a road split at a chunk boundary still meet exactly.
|
||||
*
|
||||
* Corners sharper than `maxTurnDegrees` are left alone on purpose. OpenStreetMap puts a vertex wherever a way
|
||||
* genuinely turns, and a gridded town is full of real right angles; rounding those would be wrong, not pretty.
|
||||
*/
|
||||
export function smoothPolyline(points: FlatPoints, iterations = 2, maxTurnDegrees = 50): FlatPoints {
|
||||
if (points.length < 6) return points;
|
||||
|
||||
// A turn of `maxTurnDegrees` away from straight has this cosine between its two directions; anything that
|
||||
// turns harder falls below it and is kept as a corner.
|
||||
const minCosine = Math.cos((maxTurnDegrees * Math.PI) / 180);
|
||||
let current = points;
|
||||
|
||||
for (let pass = 0; pass < iterations; pass++) {
|
||||
current = cutCorners(current, minCosine);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function cutCorners(points: FlatPoints, minCosine: number): FlatPoints {
|
||||
const count = points.length / 2;
|
||||
if (count < 3) return points;
|
||||
|
||||
const out: number[] = [points[0]!, points[1]!];
|
||||
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
const px = points[(i - 1) * 2]!;
|
||||
const py = points[(i - 1) * 2 + 1]!;
|
||||
const cx = points[i * 2]!;
|
||||
const cy = points[i * 2 + 1]!;
|
||||
const nx = points[(i + 1) * 2]!;
|
||||
const ny = points[(i + 1) * 2 + 1]!;
|
||||
|
||||
if (isSharp(px, py, cx, cy, nx, ny, minCosine)) {
|
||||
out.push(cx, cy);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(px + (cx - px) * 0.75, py + (cy - py) * 0.75);
|
||||
out.push(cx + (nx - cx) * 0.25, cy + (ny - cy) * 0.25);
|
||||
}
|
||||
|
||||
out.push(points[(count - 1) * 2]!, points[(count - 1) * 2 + 1]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when the direction change at the middle point exceeds the allowance. */
|
||||
function isSharp(
|
||||
px: number, py: number,
|
||||
cx: number, cy: number,
|
||||
nx: number, ny: number,
|
||||
minCosine: number,
|
||||
): boolean {
|
||||
const ax = cx - px;
|
||||
const ay = cy - py;
|
||||
const bx = nx - cx;
|
||||
const by = ny - cy;
|
||||
|
||||
const aLength = Math.hypot(ax, ay);
|
||||
const bLength = Math.hypot(bx, by);
|
||||
if (aLength === 0 || bLength === 0) return true;
|
||||
|
||||
// cos of the angle between the incoming and outgoing directions: 1 is straight on, -1 doubles back.
|
||||
const cosine = (ax * bx + ay * by) / (aLength * bLength);
|
||||
return cosine < minCosine;
|
||||
}
|
||||
|
||||
export interface PointOnPath {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Direction of travel at that point, in radians. */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
/** Places markers at a fixed spacing along a polyline, each oriented along the direction of travel. */
|
||||
export function pointsAlong(points: FlatPoints, spacing: number, offset = 0): PointOnPath[] {
|
||||
const placed: PointOnPath[] = [];
|
||||
if (spacing <= 0 || points.length < 4) return placed;
|
||||
|
||||
let nextAt = offset;
|
||||
let travelled = 0;
|
||||
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
const x0 = points[i]!;
|
||||
const y0 = points[i + 1]!;
|
||||
const x1 = points[i + 2]!;
|
||||
const y1 = points[i + 3]!;
|
||||
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (length === 0) continue;
|
||||
|
||||
const angle = Math.atan2(y1 - y0, x1 - x0);
|
||||
|
||||
while (nextAt <= travelled + length) {
|
||||
const t = (nextAt - travelled) / length;
|
||||
placed.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, angle });
|
||||
nextAt += spacing;
|
||||
}
|
||||
|
||||
travelled += length;
|
||||
}
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
export function polylineLength(points: FlatPoints): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
total += Math.hypot(points[i + 2]! - points[i]!, points[i + 3]! - points[i + 1]!);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/** The midpoint of a polyline by arc length, with the local direction — where a street label wants to sit. */
|
||||
export function polylineAnchor(points: FlatPoints): PointOnPath | null {
|
||||
const half = polylineLength(points) / 2;
|
||||
if (half === 0) return null;
|
||||
|
||||
const [anchor] = pointsAlong(points, Number.MAX_VALUE, half);
|
||||
return anchor ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Area-weighted centroid of a ring, which sits inside the shape even when it is long or bent — unlike the
|
||||
* average of its vertices, which drifts toward whichever edge has the most detail.
|
||||
*/
|
||||
export function polygonCentroid(points: FlatPoints): { x: number; y: number } | null {
|
||||
const count = points.length / 2;
|
||||
if (count < 3) return null;
|
||||
|
||||
let twiceArea = 0;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const j = (i + 1) % count;
|
||||
const xi = points[i * 2]!;
|
||||
const yi = points[i * 2 + 1]!;
|
||||
const xj = points[j * 2]!;
|
||||
const yj = points[j * 2 + 1]!;
|
||||
|
||||
const cross = xi * yj - xj * yi;
|
||||
twiceArea += cross;
|
||||
x += (xi + xj) * cross;
|
||||
y += (yi + yj) * cross;
|
||||
}
|
||||
|
||||
// A degenerate ring has no area to weight by; fall back to the plain vertex average.
|
||||
if (Math.abs(twiceArea) < 1e-6) {
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
sumX += points[i * 2]!;
|
||||
sumY += points[i * 2 + 1]!;
|
||||
}
|
||||
|
||||
return { x: sumX / count, y: sumY / count };
|
||||
}
|
||||
|
||||
return { x: x / (3 * twiceArea), y: y / (3 * twiceArea) };
|
||||
}
|
||||
|
||||
/** Copies a flat point array shifted by a fixed offset. */
|
||||
export function translate(points: FlatPoints, dx: number, dy: number): FlatPoints {
|
||||
const moved = new Array<number>(points.length);
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
moved[i] = points[i]! + dx;
|
||||
moved[i + 1] = points[i + 1]! + dy;
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */
|
||||
export function boundingArea(points: FlatPoints): number {
|
||||
if (points.length < 6) return 0;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const x = points[i]!;
|
||||
const y = points[i + 1]!;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
return (maxX - minX) * (maxY - minY);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Container, Text, TextStyle } from 'pixi.js';
|
||||
import type { MapChunk } from '../api/types';
|
||||
import { Camera, type Viewport } from './camera';
|
||||
import type { DetailLevel } from './chunkRenderer';
|
||||
import { collectLabelCandidates, placeLabels, type PlacedLabel } from './labelPlacement';
|
||||
import type { Theme } from './theme';
|
||||
|
||||
/**
|
||||
* Place names, drawn in screen space rather than in the scaled world container so text stays the same size
|
||||
* at every zoom.
|
||||
*
|
||||
* Deciding *which* names to show is comparatively expensive and runs on the same slow timer as chunk
|
||||
* bookkeeping. Moving them to follow the map is nearly free and runs every frame — otherwise the labels lag
|
||||
* a fast pan by up to a tenth of a second and snap back when the next placement lands.
|
||||
*/
|
||||
export class LabelLayer {
|
||||
readonly container = new Container();
|
||||
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private placed: Array<{ label: PlacedLabel; text: Text }> = [];
|
||||
private style: TextStyle;
|
||||
|
||||
constructor(theme: Theme) {
|
||||
this.style = createStyle(theme);
|
||||
this.container.label = 'labels';
|
||||
}
|
||||
|
||||
setTheme(theme: Theme): void {
|
||||
this.style = createStyle(theme);
|
||||
|
||||
// Colours live in the style object, so every pooled label has to be rebuilt against the new one.
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const text of this.texts.values()) text.destroy();
|
||||
this.texts.clear();
|
||||
this.placed = [];
|
||||
this.container.removeChildren();
|
||||
}
|
||||
|
||||
/** Chooses the labels to show. Comparatively expensive; call it on a timer, not every frame. */
|
||||
update(chunks: Iterable<MapChunk>, camera: Camera, viewport: Viewport, detail: DetailLevel): void {
|
||||
const labels = placeLabels(collectLabelCandidates(chunks, detail), camera, viewport);
|
||||
const kept = new Set<string>();
|
||||
|
||||
this.placed = labels.map((label) => {
|
||||
kept.add(label.key);
|
||||
const text = this.textFor(label);
|
||||
text.rotation = label.rotation;
|
||||
return { label, text };
|
||||
});
|
||||
|
||||
for (const [key, text] of this.texts) {
|
||||
if (kept.has(key)) continue;
|
||||
text.destroy();
|
||||
this.texts.delete(key);
|
||||
}
|
||||
|
||||
this.reposition(camera, viewport);
|
||||
}
|
||||
|
||||
/** Moves the labels already chosen to wherever the camera now puts them. Cheap; call it every frame. */
|
||||
reposition(camera: Camera, viewport: Viewport): void {
|
||||
for (const { label, text } of this.placed) {
|
||||
const screen = camera.worldToScreen(label.worldX, label.worldY, viewport);
|
||||
text.position.set(screen.x, screen.y);
|
||||
}
|
||||
}
|
||||
|
||||
private textFor(label: PlacedLabel): Text {
|
||||
const existing = this.texts.get(label.key);
|
||||
if (existing) return existing;
|
||||
|
||||
const text = new Text({ text: label.text, style: this.style });
|
||||
text.anchor.set(0.5);
|
||||
text.resolution = window.devicePixelRatio || 1;
|
||||
text.scale.set(label.size / this.style.fontSize);
|
||||
|
||||
this.texts.set(label.key, text);
|
||||
this.container.addChild(text);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function createStyle(theme: Theme): TextStyle {
|
||||
return new TextStyle({
|
||||
fontFamily: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif',
|
||||
// One size is rasterised and scaled per label, so a pool of names costs a handful of textures, not one each.
|
||||
fontSize: 26,
|
||||
fontWeight: '600',
|
||||
fill: theme.label,
|
||||
// The halo is what lets a street name stay readable while crossing the road it names.
|
||||
stroke: { color: theme.labelHalo, width: 5, join: 'round' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AreaFeature, MapChunk, RoadFeature, WaterFeature } from '../api/types';
|
||||
import { Camera, type Viewport } from './camera';
|
||||
import { collectLabelCandidates, placeLabels, type LabelCandidate } from './labelPlacement';
|
||||
|
||||
const viewport: Viewport = { width: 1000, height: 800 };
|
||||
|
||||
function camera(zoom = 1, x = 0, y = 0): Camera {
|
||||
const c = new Camera();
|
||||
c.zoom = zoom;
|
||||
c.x = x;
|
||||
c.y = y;
|
||||
return c;
|
||||
}
|
||||
|
||||
function chunk(parts: Partial<MapChunk> = {}): MapChunk {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bounds: [-1000, -1000, 1000, 1000],
|
||||
buildings: [],
|
||||
roads: [],
|
||||
areas: [],
|
||||
water: [],
|
||||
...parts,
|
||||
};
|
||||
}
|
||||
|
||||
const road = (parts: Partial<RoadFeature> = {}): RoadFeature => ({
|
||||
id: 1,
|
||||
class: 'residential',
|
||||
width: 6,
|
||||
path: [-200, 0, 200, 0],
|
||||
name: 'Main Street',
|
||||
...parts,
|
||||
});
|
||||
|
||||
const area = (parts: Partial<AreaFeature> = {}): AreaFeature => ({
|
||||
id: 1,
|
||||
kind: 'park',
|
||||
name: 'Lake View Park',
|
||||
outline: [-200, -200, 200, -200, 200, 200, -200, 200],
|
||||
...parts,
|
||||
});
|
||||
|
||||
const water = (parts: Partial<WaterFeature> = {}): WaterFeature => ({
|
||||
id: 1,
|
||||
kind: 'lake',
|
||||
name: 'Spence Reservoir',
|
||||
outline: [-300, -300, 300, -300, 300, 300, -300, 300],
|
||||
...parts,
|
||||
});
|
||||
|
||||
const candidate = (parts: Partial<LabelCandidate> = {}): LabelCandidate => ({
|
||||
key: 'k',
|
||||
text: 'Name',
|
||||
worldX: 0,
|
||||
worldY: 0,
|
||||
angle: 0,
|
||||
priority: 50,
|
||||
size: 12,
|
||||
maxWidth: 10_000,
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('collectLabelCandidates', () => {
|
||||
it('folds a road split across chunks back into one label', () => {
|
||||
// Splitting at chunk boundaries means the same street arrives as several features sharing an OSM id.
|
||||
const chunks = [
|
||||
chunk({ roads: [road({ id: 7, path: [-400, 0, -100, 0] })] }),
|
||||
chunk({ roads: [road({ id: 7, path: [-100, 0, 500, 0] })] }),
|
||||
chunk({ roads: [road({ id: 7, path: [500, 0, 560, 0] })] }),
|
||||
];
|
||||
|
||||
const found = collectLabelCandidates(chunks, 2).filter((c) => c.key === 'r7');
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
// The longest piece carries the name, so the label sits on the part of the street you can actually see.
|
||||
expect(found[0]!.worldX).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it('skips features with no name', () => {
|
||||
const chunks = [chunk({
|
||||
roads: [road({ name: undefined })],
|
||||
areas: [area({ name: undefined })],
|
||||
water: [water({ name: undefined })],
|
||||
})];
|
||||
|
||||
expect(collectLabelCandidates(chunks, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores watercourses, which have no outline to sit in', () => {
|
||||
const chunks = [chunk({ water: [{ id: 3, kind: 'stream', name: 'Dry Creek', path: [0, 0, 100, 0] }] })];
|
||||
|
||||
expect(collectLabelCandidates(chunks, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops minor streets as the camera pulls back', () => {
|
||||
const chunks = [chunk({
|
||||
roads: [
|
||||
road({ id: 1, class: 'motorway', name: 'US 208' }),
|
||||
road({ id: 2, class: 'residential', name: 'Elm Street' }),
|
||||
road({ id: 3, class: 'footway', name: 'River Walk' }),
|
||||
],
|
||||
})];
|
||||
|
||||
const keys = (detail: 0 | 1 | 2) => collectLabelCandidates(chunks, detail).map((c) => c.key).sort();
|
||||
|
||||
expect(keys(2)).toEqual(['r1', 'r2', 'r3']);
|
||||
expect(keys(1)).toEqual(['r1', 'r2']);
|
||||
expect(keys(0)).toEqual(['r1']);
|
||||
});
|
||||
|
||||
it('drops small areas as the camera pulls back', () => {
|
||||
// A 400x400 m park spans 400 m, which clears the town threshold but not the far one.
|
||||
const chunks = [chunk({ areas: [area()] })];
|
||||
|
||||
expect(collectLabelCandidates(chunks, 2)).toHaveLength(1);
|
||||
expect(collectLabelCandidates(chunks, 1)).toHaveLength(1);
|
||||
expect(collectLabelCandidates(chunks, 0)).toHaveLength(1);
|
||||
|
||||
const small = [chunk({ areas: [area({ outline: [0, 0, 30, 0, 30, 30, 0, 30] })] })];
|
||||
expect(collectLabelCandidates(small, 2)).toHaveLength(1);
|
||||
expect(collectLabelCandidates(small, 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ranks landmarks first, then arterials, then land cover, then side streets', () => {
|
||||
const chunks = [chunk({
|
||||
water: [water()],
|
||||
areas: [area()],
|
||||
roads: [road({ id: 1, class: 'primary', name: 'Highway' }), road({ id: 2, class: 'service', name: 'Alley' })],
|
||||
})];
|
||||
|
||||
const found = new Map(collectLabelCandidates(chunks, 2).map((c) => [c.key, c.priority]));
|
||||
|
||||
// Water bodies are what people orient by; an arterial names more of the map than the park beside it.
|
||||
expect(found.get('w1')!).toBeGreaterThan(found.get('r1')!);
|
||||
expect(found.get('r1')!).toBeGreaterThan(found.get('a1')!);
|
||||
expect(found.get('a1')!).toBeGreaterThan(found.get('r2')!);
|
||||
});
|
||||
|
||||
it('measures how much room a road label has along its own line', () => {
|
||||
const found = collectLabelCandidates([chunk({ roads: [road({ path: [0, 0, 1000, 0] })] })], 2);
|
||||
|
||||
expect(found[0]!.maxWidth).toBeCloseTo(900, 6);
|
||||
});
|
||||
|
||||
it('places a road label along the direction of the road', () => {
|
||||
const found = collectLabelCandidates([chunk({ roads: [road({ path: [0, -300, 0, 300] })] })], 2);
|
||||
|
||||
expect(found[0]!.angle).toBeCloseTo(Math.PI / 2, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeLabels', () => {
|
||||
it('places the highest priority first when two collide', () => {
|
||||
const placed = placeLabels(
|
||||
[candidate({ key: 'low', priority: 10 }), candidate({ key: 'high', priority: 90 })],
|
||||
camera(),
|
||||
viewport,
|
||||
);
|
||||
|
||||
expect(placed.map((p) => p.key)).toEqual(['high']);
|
||||
});
|
||||
|
||||
it('keeps labels that are far enough apart', () => {
|
||||
const placed = placeLabels(
|
||||
[candidate({ key: 'a', worldX: -300 }), candidate({ key: 'b', worldX: 300 })],
|
||||
camera(),
|
||||
viewport,
|
||||
);
|
||||
|
||||
expect(placed.map((p) => p.key).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('drops anything off screen', () => {
|
||||
const placed = placeLabels([candidate({ key: 'far', worldX: 50_000 })], camera(), viewport);
|
||||
|
||||
expect(placed).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a name too long for the thing it names', () => {
|
||||
const long = candidate({ text: 'A Very Long Street Name Indeed', maxWidth: 20 });
|
||||
|
||||
expect(placeLabels([long], camera(1), viewport)).toEqual([]);
|
||||
// Zooming in gives the same road more pixels, so the same name now fits.
|
||||
expect(placeLabels([long], camera(20), viewport)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never returns more than the ceiling', () => {
|
||||
const many = Array.from({ length: 400 }, (_, i) =>
|
||||
candidate({ key: `k${i}`, worldX: (i % 20) * 5, worldY: Math.floor(i / 20) * 5, maxWidth: 1e9 }),
|
||||
);
|
||||
|
||||
expect(placeLabels(many, camera(), viewport, 5).length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('turns text the right way up whichever way the road runs', () => {
|
||||
// A road heading south-west would read upside down at its raw angle.
|
||||
const placed = placeLabels([candidate({ angle: (Math.PI * 5) / 6 })], camera(), viewport);
|
||||
|
||||
expect(Math.abs(placed[0]!.rotation)).toBeLessThanOrEqual(Math.PI / 2 + 1e-9);
|
||||
});
|
||||
|
||||
it('mirrors the world angle, because screen Y points the other way', () => {
|
||||
const placed = placeLabels([candidate({ angle: Math.PI / 6 })], camera(), viewport);
|
||||
|
||||
expect(placed[0]!.rotation).toBeCloseTo(-Math.PI / 6, 6);
|
||||
});
|
||||
|
||||
it('carries the world position through, so the layer can follow the camera each frame', () => {
|
||||
const placed = placeLabels([candidate({ worldX: 120, worldY: -45 })], camera(), viewport);
|
||||
|
||||
expect(placed[0]!.worldX).toBe(120);
|
||||
expect(placed[0]!.worldY).toBe(-45);
|
||||
});
|
||||
|
||||
it('is stable for equal priorities, so labels do not swap between passes', () => {
|
||||
const pair = [candidate({ key: 'b', worldX: -300 }), candidate({ key: 'a', worldX: 300 })];
|
||||
|
||||
const first = placeLabels(pair, camera(), viewport).map((p) => p.key);
|
||||
const second = placeLabels([...pair].reverse(), camera(), viewport).map((p) => p.key);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { MapChunk, RoadFeature } from '../api/types';
|
||||
import { Camera, type Viewport } from './camera';
|
||||
import type { DetailLevel } from './chunkRenderer';
|
||||
import { polygonCentroid, polylineAnchor, polylineLength } from './geometry';
|
||||
import { ROAD_RANK } from './style';
|
||||
|
||||
/**
|
||||
* Deciding which place names to show and where. Kept free of PixiJS so the ranking and the collision rules
|
||||
* can be tested on their own; `labelLayer.ts` is the thin part that turns the result into text objects.
|
||||
*/
|
||||
|
||||
export interface LabelCandidate {
|
||||
/** Stable across frames, so a label that survives a pan keeps its text object instead of being rebuilt. */
|
||||
key: string;
|
||||
text: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
/** World-space direction the label follows; zero for anything placed horizontally. */
|
||||
angle: number;
|
||||
priority: number;
|
||||
size: number;
|
||||
/** Longest the label may be on screen, in world metres; drops names that will not fit their road. */
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
export interface PlacedLabel {
|
||||
key: string;
|
||||
text: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
/** Screen-space rotation, already turned the right way up. */
|
||||
rotation: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const MAX_LABELS = 140;
|
||||
|
||||
/** Rough width of a character relative to the font size, used for the collision box before layout. */
|
||||
const CHARACTER_WIDTH_RATIO = 0.52;
|
||||
|
||||
/** Breathing room around each label so neighbours do not touch. */
|
||||
const LABEL_PADDING = 4;
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function collectLabelCandidates(chunks: Iterable<MapChunk>, detail: DetailLevel): LabelCandidate[] {
|
||||
const candidates: LabelCandidate[] = [];
|
||||
|
||||
// A road split across chunks keeps its OSM id, so the pieces are folded back together and the longest one
|
||||
// carries the name. Without this a street picks up a label per chunk it crosses.
|
||||
const roadsById = new Map<number, RoadFeature>();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
for (const road of chunk.roads) {
|
||||
if (!road.name) continue;
|
||||
if (ROAD_RANK[road.class] < minimumRoadRank(detail)) continue;
|
||||
|
||||
const existing = roadsById.get(road.id);
|
||||
if (!existing || polylineLength(road.path) > polylineLength(existing.path)) {
|
||||
roadsById.set(road.id, road);
|
||||
}
|
||||
}
|
||||
|
||||
for (const area of chunk.areas) {
|
||||
if (!area.name) continue;
|
||||
|
||||
const centroid = polygonCentroid(area.outline);
|
||||
if (!centroid) continue;
|
||||
|
||||
const size = Math.sqrt(boundingArea(area.outline));
|
||||
if (size < minimumAreaSpan(detail)) continue;
|
||||
|
||||
candidates.push({
|
||||
key: `a${area.id}`,
|
||||
text: area.name,
|
||||
worldX: centroid.x,
|
||||
worldY: centroid.y,
|
||||
angle: 0,
|
||||
priority: 70 + Math.min(size / 100, 15),
|
||||
size: 12,
|
||||
maxWidth: size,
|
||||
});
|
||||
}
|
||||
|
||||
for (const water of chunk.water) {
|
||||
if (!water.name || !water.outline) continue;
|
||||
|
||||
const centroid = polygonCentroid(water.outline);
|
||||
if (!centroid) continue;
|
||||
|
||||
const size = Math.sqrt(boundingArea(water.outline));
|
||||
if (size < minimumAreaSpan(detail)) continue;
|
||||
|
||||
candidates.push({
|
||||
key: `w${water.id}`,
|
||||
text: water.name,
|
||||
worldX: centroid.x,
|
||||
worldY: centroid.y,
|
||||
angle: 0,
|
||||
// Water bodies are the landmarks people orient by, so they outrank the land around them.
|
||||
priority: 95 + Math.min(size / 100, 15),
|
||||
size: 13,
|
||||
maxWidth: size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const road of roadsById.values()) {
|
||||
const anchor = polylineAnchor(road.path);
|
||||
if (!anchor) continue;
|
||||
|
||||
const rank = ROAD_RANK[road.class];
|
||||
candidates.push({
|
||||
key: `r${road.id}`,
|
||||
text: road.name!,
|
||||
worldX: anchor.x,
|
||||
worldY: anchor.y,
|
||||
angle: anchor.angle,
|
||||
priority: 40 + rank * 5,
|
||||
size: rank >= 7 ? 12 : 11,
|
||||
// A name that would run off the end of its own road is worse than no name at all.
|
||||
maxWidth: polylineLength(road.path) * 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places what fits, most important first. The first label to claim a piece of screen keeps it; anything that
|
||||
* would overlap is dropped rather than nudged, which keeps the result stable as the camera moves.
|
||||
*/
|
||||
export function placeLabels(
|
||||
candidates: LabelCandidate[],
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
maxLabels = MAX_LABELS,
|
||||
): PlacedLabel[] {
|
||||
const ordered = [...candidates].sort((a, b) => b.priority - a.priority || a.key.localeCompare(b.key));
|
||||
|
||||
const placed: PlacedLabel[] = [];
|
||||
const boxes: Rect[] = [];
|
||||
|
||||
for (const candidate of ordered) {
|
||||
if (placed.length >= maxLabels) break;
|
||||
|
||||
const screen = camera.worldToScreen(candidate.worldX, candidate.worldY, viewport);
|
||||
if (screen.x < 0 || screen.y < 0 || screen.x > viewport.width || screen.y > viewport.height) continue;
|
||||
|
||||
const width = candidate.text.length * candidate.size * CHARACTER_WIDTH_RATIO;
|
||||
if (width > candidate.maxWidth * camera.zoom) continue;
|
||||
|
||||
// World Y grows north and screen Y grows down, so the on-screen angle is the world angle mirrored.
|
||||
const rotation = uprightRotation(-candidate.angle);
|
||||
const box = boundsOf(screen.x, screen.y, width, candidate.size, rotation);
|
||||
if (boxes.some((other) => overlaps(box, other))) continue;
|
||||
|
||||
boxes.push(box);
|
||||
placed.push({
|
||||
key: candidate.key,
|
||||
text: candidate.text,
|
||||
worldX: candidate.worldX,
|
||||
worldY: candidate.worldY,
|
||||
rotation,
|
||||
size: candidate.size,
|
||||
});
|
||||
}
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
/** Which roads are worth naming at each zoom: everything at street level, only the arterials from afar. */
|
||||
function minimumRoadRank(detail: DetailLevel): number {
|
||||
return detail === 2 ? 0 : detail === 1 ? 5 : 9;
|
||||
}
|
||||
|
||||
function minimumAreaSpan(detail: DetailLevel): number {
|
||||
return detail === 2 ? 25 : detail === 1 ? 90 : 300;
|
||||
}
|
||||
|
||||
function boundingArea(points: number[]): number {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const x = points[i]!;
|
||||
const y = points[i + 1]!;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
return Math.max(0, (maxX - minX) * (maxY - minY));
|
||||
}
|
||||
|
||||
/** Keeps text the right way up: past a quarter turn, reading direction flips. */
|
||||
function uprightRotation(rotation: number): number {
|
||||
let upright = rotation;
|
||||
while (upright > Math.PI / 2) upright -= Math.PI;
|
||||
while (upright < -Math.PI / 2) upright += Math.PI;
|
||||
return upright;
|
||||
}
|
||||
|
||||
function boundsOf(x: number, y: number, width: number, height: number, rotation: number): Rect {
|
||||
const cos = Math.abs(Math.cos(rotation));
|
||||
const sin = Math.abs(Math.sin(rotation));
|
||||
|
||||
const halfWidth = (width * cos + height * sin) / 2 + LABEL_PADDING;
|
||||
const halfHeight = (width * sin + height * cos) / 2 + LABEL_PADDING;
|
||||
|
||||
return { left: x - halfWidth, top: y - halfHeight, right: x + halfWidth, bottom: y + halfHeight };
|
||||
}
|
||||
|
||||
function overlaps(a: Rect, b: Rect): boolean {
|
||||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AreaKind, RoadClass, RoadFeature } from '../api/types';
|
||||
import { RoadFlags } from '../api/types';
|
||||
import { areaLayer, LAYER_ORDER, ROAD_BANDS, roadBand, roadLayers } from './layers';
|
||||
import { ROAD_MIN_DETAIL, ROAD_RANK } from './style';
|
||||
|
||||
const road = (roadClass: RoadClass, flags = 0): RoadFeature => ({
|
||||
id: 1,
|
||||
class: roadClass,
|
||||
width: 6,
|
||||
path: [0, 0, 10, 0],
|
||||
flags,
|
||||
});
|
||||
|
||||
const order = (layer: string) => LAYER_ORDER.indexOf(layer as (typeof LAYER_ORDER)[number]);
|
||||
|
||||
describe('LAYER_ORDER', () => {
|
||||
it('has no duplicates', () => {
|
||||
expect(new Set(LAYER_ORDER).size).toBe(LAYER_ORDER.length);
|
||||
});
|
||||
|
||||
it('puts every casing under its own fill', () => {
|
||||
// This is what makes junctions merge into one surface instead of showing each other's outlines.
|
||||
for (const band of ROAD_BANDS) {
|
||||
const { casing, fill } = roadLayers(band);
|
||||
expect(order(casing)).toBeGreaterThanOrEqual(0);
|
||||
expect(order(casing)).toBeLessThan(order(fill));
|
||||
}
|
||||
});
|
||||
|
||||
it('stacks the map the way a map stacks: ground, water, roads, buildings', () => {
|
||||
expect(order('areaZones')).toBeLessThan(order('areaNatural'));
|
||||
expect(order('areaNatural')).toBeLessThan(order('areaDetail'));
|
||||
expect(order('areaDetail')).toBeLessThan(order('water'));
|
||||
expect(order('water')).toBeLessThan(order('minorCasing'));
|
||||
expect(order('bridgeFill')).toBeLessThan(order('buildingWalls'));
|
||||
expect(order('buildingWalls')).toBeLessThan(order('buildings'));
|
||||
});
|
||||
|
||||
it('runs road bands from least to most important, with tunnels below and bridges above', () => {
|
||||
expect(order('tunnelFill')).toBeLessThan(order('minorCasing'));
|
||||
expect(order('minorFill')).toBeLessThan(order('localCasing'));
|
||||
expect(order('localFill')).toBeLessThan(order('secondaryCasing'));
|
||||
expect(order('secondaryFill')).toBeLessThan(order('majorCasing'));
|
||||
expect(order('majorFill')).toBeLessThan(order('bridgeCasing'));
|
||||
});
|
||||
|
||||
it('draws one-way arrows over every road band', () => {
|
||||
expect(order('bridgeFill')).toBeLessThan(order('roadArrows'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('roadBand', () => {
|
||||
it('sorts classes into bands by importance', () => {
|
||||
expect(roadBand(road('motorway'))).toBe('major');
|
||||
expect(roadBand(road('trunk'))).toBe('major');
|
||||
expect(roadBand(road('primary'))).toBe('major');
|
||||
expect(roadBand(road('secondary'))).toBe('secondary');
|
||||
expect(roadBand(road('tertiary'))).toBe('secondary');
|
||||
expect(roadBand(road('residential'))).toBe('local');
|
||||
expect(roadBand(road('livingStreet'))).toBe('local');
|
||||
expect(roadBand(road('service'))).toBe('minor');
|
||||
expect(roadBand(road('footway'))).toBe('minor');
|
||||
expect(roadBand(road('railway'))).toBe('minor');
|
||||
});
|
||||
|
||||
it('lets bridges and tunnels override the class', () => {
|
||||
// A motorway bridge belongs above whatever it crosses, not in the band its class would earn.
|
||||
expect(roadBand(road('motorway', RoadFlags.bridge))).toBe('bridge');
|
||||
expect(roadBand(road('footway', RoadFlags.bridge))).toBe('bridge');
|
||||
expect(roadBand(road('motorway', RoadFlags.tunnel))).toBe('tunnel');
|
||||
});
|
||||
|
||||
it('prefers bridge over tunnel when a way is somehow tagged both', () => {
|
||||
expect(roadBand(road('primary', RoadFlags.bridge | RoadFlags.tunnel))).toBe('bridge');
|
||||
});
|
||||
|
||||
it('ignores flags that say nothing about height', () => {
|
||||
expect(roadBand(road('residential', RoadFlags.oneway | RoadFlags.unpaved))).toBe('local');
|
||||
});
|
||||
|
||||
it('handles a road with no flags at all', () => {
|
||||
const { flags: _flags, ...bare } = road('residential');
|
||||
expect(roadBand(bare as RoadFeature)).toBe('local');
|
||||
});
|
||||
|
||||
it('names layers that actually exist', () => {
|
||||
for (const band of ROAD_BANDS) {
|
||||
const { casing, fill } = roadLayers(band);
|
||||
expect(LAYER_ORDER).toContain(casing);
|
||||
expect(LAYER_ORDER).toContain(fill);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('areaLayer', () => {
|
||||
it('puts deliberate spaces above natural cover, and both above zoning', () => {
|
||||
expect(areaLayer('park')).toBe('areaDetail');
|
||||
expect(areaLayer('pitch')).toBe('areaDetail');
|
||||
expect(areaLayer('forest')).toBe('areaNatural');
|
||||
expect(areaLayer('farmland')).toBe('areaNatural');
|
||||
expect(areaLayer('residentialZone')).toBe('areaZones');
|
||||
expect(areaLayer('parking')).toBe('areaZones');
|
||||
});
|
||||
|
||||
it('sends anything unrecognised to the bottom band', () => {
|
||||
expect(areaLayer('unknown')).toBe('areaZones');
|
||||
expect(areaLayer('nonsense' as AreaKind)).toBe('areaZones');
|
||||
});
|
||||
});
|
||||
|
||||
describe('road tables', () => {
|
||||
it('rank and detail cover the same set of classes', () => {
|
||||
expect(Object.keys(ROAD_RANK).sort()).toEqual(Object.keys(ROAD_MIN_DETAIL).sort());
|
||||
});
|
||||
|
||||
it('never hides a road that outranks one still drawn', () => {
|
||||
// A footpath must not survive to a zoom where a trunk road has already been dropped.
|
||||
const classes = Object.keys(ROAD_RANK) as RoadClass[];
|
||||
|
||||
for (const a of classes) {
|
||||
for (const b of classes) {
|
||||
if (ROAD_RANK[a] > ROAD_RANK[b]) {
|
||||
expect(ROAD_MIN_DETAIL[a]).toBeLessThanOrEqual(ROAD_MIN_DETAIL[b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import type { AreaKind, RoadFeature } from '../api/types';
|
||||
import { RoadFlags } from '../api/types';
|
||||
import { ROAD_RANK } from './style';
|
||||
|
||||
/**
|
||||
* Draw order for the whole map, back to front.
|
||||
*
|
||||
* Every chunk contributes its geometry to these shared layers rather than to a container of its own. That
|
||||
* matters: with per-chunk containers the ordering would only hold inside a chunk, so a side street loaded
|
||||
* after a trunk road would paint straight over it wherever the two meet.
|
||||
*
|
||||
* Within each band the casing goes down first and the fill on top, which is what makes junctions read as
|
||||
* junctions: the fills merge into one continuous surface instead of showing each other's outlines.
|
||||
*/
|
||||
export const LAYER_ORDER = [
|
||||
'areaZones',
|
||||
'areaNatural',
|
||||
'areaDetail',
|
||||
'water',
|
||||
'tunnelCasing',
|
||||
'tunnelFill',
|
||||
'minorCasing',
|
||||
'minorFill',
|
||||
'localCasing',
|
||||
'localFill',
|
||||
'secondaryCasing',
|
||||
'secondaryFill',
|
||||
'majorCasing',
|
||||
'majorFill',
|
||||
'bridgeCasing',
|
||||
'bridgeFill',
|
||||
'roadArrows',
|
||||
'buildingWalls',
|
||||
'buildings',
|
||||
] as const;
|
||||
|
||||
export type LayerId = (typeof LAYER_ORDER)[number];
|
||||
|
||||
export type MapLayers = Record<LayerId, Container>;
|
||||
|
||||
export type RoadBand = 'tunnel' | 'minor' | 'local' | 'secondary' | 'major' | 'bridge';
|
||||
|
||||
export const ROAD_BANDS: readonly RoadBand[] = ['tunnel', 'minor', 'local', 'secondary', 'major', 'bridge'];
|
||||
|
||||
/**
|
||||
* Bridges rise above everything they cross and tunnels sink below it; otherwise a road sits in the band its
|
||||
* class earns.
|
||||
*/
|
||||
export function roadBand(road: RoadFeature): RoadBand {
|
||||
const flags = road.flags ?? 0;
|
||||
if (flags & RoadFlags.bridge) return 'bridge';
|
||||
if (flags & RoadFlags.tunnel) return 'tunnel';
|
||||
|
||||
const rank = ROAD_RANK[road.class];
|
||||
if (rank >= 9) return 'major';
|
||||
if (rank >= 7) return 'secondary';
|
||||
if (rank >= 5) return 'local';
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
export function roadLayers(band: RoadBand): { casing: LayerId; fill: LayerId } {
|
||||
return { casing: `${band}Casing`, fill: `${band}Fill` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Land cover in three passes: zoning blocks underneath, natural cover over them, and the small deliberate
|
||||
* spaces — parks, gardens, pitches — on top.
|
||||
*/
|
||||
export function areaLayer(kind: AreaKind): LayerId {
|
||||
switch (kind) {
|
||||
case 'park':
|
||||
case 'garden':
|
||||
case 'pitch':
|
||||
case 'cemetery':
|
||||
case 'beach':
|
||||
return 'areaDetail';
|
||||
|
||||
case 'forest':
|
||||
case 'grass':
|
||||
case 'meadow':
|
||||
case 'orchard':
|
||||
case 'scrub':
|
||||
case 'heath':
|
||||
case 'sand':
|
||||
case 'bareRock':
|
||||
case 'wetland':
|
||||
case 'farmland':
|
||||
return 'areaNatural';
|
||||
|
||||
default:
|
||||
return 'areaZones';
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import type { WorldMap } from '../api/types';
|
||||
import { Camera, type Viewport } from './camera';
|
||||
import { ChunkManager, type MapLayers } from './chunkManager';
|
||||
import { ChunkManager } from './chunkManager';
|
||||
import { profileForZoom, type RenderProfile } from './chunkRenderer';
|
||||
import { LAND_COLOR } from './style';
|
||||
import { LabelLayer } from './labelLayer';
|
||||
import { LAYER_ORDER, type MapLayers } from './layers';
|
||||
import { THEMES, type Theme, type ThemeName } from './theme';
|
||||
|
||||
const OUTSIDE_COLOR = 0xd5d9d2;
|
||||
const BORDER_COLOR = 0xb4bab2;
|
||||
/**
|
||||
* Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of
|
||||
* PixiJS values and its ordering rules can be tested without a browser.
|
||||
*/
|
||||
function createLayers(): MapLayers {
|
||||
const layers = {} as MapLayers;
|
||||
for (const id of LAYER_ORDER) {
|
||||
const container = new Container();
|
||||
container.label = id;
|
||||
layers[id] = container;
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
|
||||
const CHUNK_UPDATE_INTERVAL_MS = 90;
|
||||
@@ -21,8 +35,8 @@ export interface MapStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* The PixiJS side of the map: one scaled container holding the layer stack, a camera driving it, and the
|
||||
* pointer handling that lets the player move around.
|
||||
* The PixiJS side of the map: one scaled container holding the layer stack, a screen-space layer for place
|
||||
* names above it, a camera driving both, and the pointer handling that lets the player move around.
|
||||
*/
|
||||
export class MapView {
|
||||
private readonly app = new Application();
|
||||
@@ -30,20 +44,15 @@ export class MapView {
|
||||
private readonly background = new Graphics();
|
||||
private readonly border = new Graphics();
|
||||
private readonly camera = new Camera();
|
||||
|
||||
private readonly layers: MapLayers = {
|
||||
areas: new Container(),
|
||||
water: new Container(),
|
||||
roadCasings: new Container(),
|
||||
roads: new Container(),
|
||||
buildings: new Container(),
|
||||
};
|
||||
|
||||
private readonly layers: MapLayers = createLayers();
|
||||
private readonly chunks = new ChunkManager(this.layers);
|
||||
|
||||
private theme: Theme = THEMES.day;
|
||||
private readonly labels = new LabelLayer(this.theme);
|
||||
|
||||
private host: HTMLElement | null = null;
|
||||
private worldSizeMeters = 0;
|
||||
private profile: RenderProfile = profileForZoom(0.1);
|
||||
private profile: RenderProfile = profileForZoom(0.1, this.theme);
|
||||
private cameraDirty = true;
|
||||
private lastChunkUpdate = 0;
|
||||
private readonly activePointers = new Map<number, { x: number; y: number }>();
|
||||
@@ -55,7 +64,7 @@ export class MapView {
|
||||
this.host = host;
|
||||
|
||||
await this.app.init({
|
||||
background: OUTSIDE_COLOR,
|
||||
background: this.theme.outside,
|
||||
antialias: true,
|
||||
resizeTo: host,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
@@ -65,18 +74,15 @@ export class MapView {
|
||||
|
||||
host.appendChild(this.app.canvas);
|
||||
|
||||
// Painter's order: land cover, then water, then the road casings their fills sit on, then buildings.
|
||||
this.root.addChild(
|
||||
this.background,
|
||||
this.layers.areas,
|
||||
this.layers.water,
|
||||
this.layers.roadCasings,
|
||||
this.layers.roads,
|
||||
this.layers.buildings,
|
||||
this.border,
|
||||
);
|
||||
this.root.addChild(this.background);
|
||||
for (const id of LAYER_ORDER) this.root.addChild(this.layers[id]);
|
||||
this.root.addChild(this.border);
|
||||
|
||||
this.app.stage.addChild(this.root);
|
||||
|
||||
// Labels sit outside the scaled container so they keep a constant size on screen.
|
||||
this.app.stage.addChild(this.labels.container);
|
||||
|
||||
this.attachInput(this.app.canvas);
|
||||
|
||||
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
|
||||
@@ -84,24 +90,44 @@ export class MapView {
|
||||
this.cameraDirty = true;
|
||||
}).observe(host);
|
||||
|
||||
this.app.ticker.add(() => this.tick());
|
||||
this.app.ticker.add((ticker) => this.tick(ticker.deltaMS));
|
||||
}
|
||||
|
||||
showWorld(map: WorldMap): void {
|
||||
this.chunks.clear();
|
||||
this.labels.clear();
|
||||
this.worldSizeMeters = map.sizeMeters;
|
||||
|
||||
const half = map.sizeMeters / 2;
|
||||
this.background.clear().rect(-half, -half, map.sizeMeters, map.sizeMeters).fill({ color: LAND_COLOR });
|
||||
|
||||
this.paintBackground();
|
||||
this.camera.fit(map.sizeMeters, this.viewport);
|
||||
this.chunks.setWorld(map.id, map.chunks);
|
||||
this.cameraDirty = true;
|
||||
this.lastChunkUpdate = 0;
|
||||
}
|
||||
|
||||
get themeName(): ThemeName {
|
||||
return this.theme.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching theme changes the render profile key, which is the same signal a zoom change uses — so every
|
||||
* loaded chunk redraws through the usual dissolve rather than through a special case.
|
||||
*/
|
||||
setTheme(name: ThemeName): void {
|
||||
if (this.theme.name === name) return;
|
||||
|
||||
this.theme = THEMES[name];
|
||||
this.labels.setTheme(this.theme);
|
||||
this.app.renderer.background.color = this.theme.outside;
|
||||
|
||||
this.paintBackground();
|
||||
this.cameraDirty = true;
|
||||
this.lastChunkUpdate = 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.chunks.clear();
|
||||
this.labels.clear();
|
||||
this.background.clear();
|
||||
this.border.clear();
|
||||
this.worldSizeMeters = 0;
|
||||
@@ -109,6 +135,7 @@ export class MapView {
|
||||
|
||||
destroy(): void {
|
||||
this.chunks.clear();
|
||||
this.labels.clear();
|
||||
this.app.destroy(true, { children: true });
|
||||
}
|
||||
|
||||
@@ -119,7 +146,17 @@ export class MapView {
|
||||
};
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
private paintBackground(): void {
|
||||
if (this.worldSizeMeters === 0) return;
|
||||
|
||||
const half = this.worldSizeMeters / 2;
|
||||
this.background
|
||||
.clear()
|
||||
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters)
|
||||
.fill({ color: this.theme.land });
|
||||
}
|
||||
|
||||
private tick(deltaMs: number): void {
|
||||
if (this.worldSizeMeters === 0) return;
|
||||
|
||||
const viewport = this.viewport;
|
||||
@@ -131,15 +168,26 @@ export class MapView {
|
||||
this.root.position.set(position.x, position.y);
|
||||
this.root.scale.set(this.camera.zoom, -this.camera.zoom);
|
||||
|
||||
this.profile = profileForZoom(this.camera.zoom);
|
||||
this.profile = profileForZoom(this.camera.zoom, this.theme);
|
||||
this.drawBorder();
|
||||
this.cameraDirty = false;
|
||||
}
|
||||
|
||||
// Drawing is metered per frame; reconciling what should be on screen runs on the slower timer below.
|
||||
this.chunks.processDrawQueue(this.profile);
|
||||
this.chunks.advanceFades(deltaMs);
|
||||
|
||||
// Labels live in screen space, so they have to follow the camera every frame. Choosing them is the
|
||||
// expensive half and stays on the timer; without this split they lag a fast pan and then snap back.
|
||||
this.labels.reposition(this.camera, viewport);
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) {
|
||||
this.lastChunkUpdate = now;
|
||||
this.chunks.update(this.camera.visibleRect(viewport), this.profile);
|
||||
|
||||
const visible = this.camera.visibleRect(viewport);
|
||||
this.chunks.update(visible, this.profile);
|
||||
this.labels.update(this.chunks.visibleChunks(visible), this.camera, viewport, this.profile.detail);
|
||||
this.publishStatus();
|
||||
}
|
||||
}
|
||||
@@ -149,7 +197,7 @@ export class MapView {
|
||||
this.border
|
||||
.clear()
|
||||
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters)
|
||||
.stroke({ width: 2 / this.camera.zoom, color: BORDER_COLOR, alignment: 1 });
|
||||
.stroke({ width: 2 / this.camera.zoom, color: this.theme.border, alignment: 1 });
|
||||
}
|
||||
|
||||
private publishStatus(): void {
|
||||
|
||||
@@ -1,109 +1,30 @@
|
||||
import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types';
|
||||
import type { RoadClass } from '../api/types';
|
||||
|
||||
/**
|
||||
* A quiet daytime cartography palette: muted land cover, white roads, warm building fills. Everything the
|
||||
* renderer draws gets its colour from here so the map reads as one coherent style.
|
||||
* Structural drawing rules that hold whatever the palette is: which roads outrank which, when each stops
|
||||
* being worth drawing, and which ones are conventionally dashed. Colours live in `theme.ts`.
|
||||
*/
|
||||
|
||||
export const LAND_COLOR = 0xeceee7;
|
||||
|
||||
export const AREA_COLORS: Record<AreaKind, number> = {
|
||||
unknown: 0xe6e6e0,
|
||||
forest: 0xc6ddb8,
|
||||
grass: 0xd8e9c6,
|
||||
meadow: 0xdfeecd,
|
||||
farmland: 0xece7c8,
|
||||
orchard: 0xd9e7bd,
|
||||
scrub: 0xd9e6c9,
|
||||
heath: 0xdfe4cb,
|
||||
sand: 0xf0e9cf,
|
||||
bareRock: 0xdedcd5,
|
||||
wetland: 0xd2e2d7,
|
||||
park: 0xd2eac0,
|
||||
garden: 0xd9edc6,
|
||||
pitch: 0xc9e6b4,
|
||||
cemetery: 0xd6e0cd,
|
||||
residentialZone: 0xe6e3de,
|
||||
industrialZone: 0xe3dee2,
|
||||
commercialZone: 0xeae0dd,
|
||||
retailZone: 0xeddfd7,
|
||||
quarry: 0xdfdad2,
|
||||
parking: 0xe7e4dd,
|
||||
school: 0xeae3d7,
|
||||
beach: 0xf4ead1,
|
||||
};
|
||||
|
||||
export const WATER_COLOR = 0x9fc9e0;
|
||||
export const WATER_EDGE_COLOR = 0x84b4cf;
|
||||
|
||||
export const WATER_LINE_COLORS: Record<WaterKind, number> = {
|
||||
unknown: WATER_COLOR,
|
||||
water: WATER_COLOR,
|
||||
lake: WATER_COLOR,
|
||||
pond: WATER_COLOR,
|
||||
reservoir: WATER_COLOR,
|
||||
riverbank: WATER_COLOR,
|
||||
river: WATER_COLOR,
|
||||
stream: 0xa9d0e4,
|
||||
canal: WATER_COLOR,
|
||||
ditch: 0xb5d7e8,
|
||||
drain: 0xb5d7e8,
|
||||
};
|
||||
|
||||
export const ROAD_COLORS: Record<RoadClass, number> = {
|
||||
unknown: 0xf7f6f4,
|
||||
motorway: 0xf3ae68,
|
||||
trunk: 0xf7c489,
|
||||
primary: 0xfad89b,
|
||||
secondary: 0xfae9b8,
|
||||
tertiary: 0xfdfaf0,
|
||||
unclassified: 0xffffff,
|
||||
residential: 0xffffff,
|
||||
livingStreet: 0xf7f7f5,
|
||||
service: 0xfbfbfa,
|
||||
track: 0xe6d9bd,
|
||||
pedestrian: 0xf1eee9,
|
||||
footway: 0xecc9b3,
|
||||
cycleway: 0xd2dcef,
|
||||
steps: 0xe4b39a,
|
||||
path: 0xe8cbb6,
|
||||
railway: 0xb2aca6,
|
||||
};
|
||||
|
||||
export const ROAD_CASING_COLORS: Record<RoadClass, number> = {
|
||||
unknown: 0xd8d4cd,
|
||||
motorway: 0xd48f45,
|
||||
trunk: 0xd9a163,
|
||||
primary: 0xdcb872,
|
||||
secondary: 0xdfcb8c,
|
||||
tertiary: 0xd2cec6,
|
||||
unclassified: 0xd2cec6,
|
||||
residential: 0xd2cec6,
|
||||
livingStreet: 0xd2cec6,
|
||||
service: 0xdad6cf,
|
||||
track: 0xc7b795,
|
||||
pedestrian: 0xd6d1c9,
|
||||
footway: 0xd0a488,
|
||||
cycleway: 0xafbcd6,
|
||||
steps: 0xcb9077,
|
||||
path: 0xcfab8f,
|
||||
railway: 0x8d8781,
|
||||
};
|
||||
|
||||
/** Draw order within the road layer: bigger roads sit on top of the network they feed. */
|
||||
/**
|
||||
* Draw order within the road network: bigger roads sit on top of what feeds them.
|
||||
*
|
||||
* An unrecognised `highway` value ranks low on purpose. It could be anything — a proposed alignment, a
|
||||
* raceway, a tag nobody has taught this importer — so it is drawn under the streets we do understand rather
|
||||
* than over them.
|
||||
*/
|
||||
export const ROAD_RANK: Record<RoadClass, number> = {
|
||||
path: 0,
|
||||
steps: 0,
|
||||
footway: 1,
|
||||
cycleway: 1,
|
||||
track: 2,
|
||||
unknown: 2,
|
||||
service: 3,
|
||||
pedestrian: 3,
|
||||
railway: 4,
|
||||
livingStreet: 5,
|
||||
residential: 6,
|
||||
unclassified: 6,
|
||||
unknown: 6,
|
||||
tertiary: 7,
|
||||
secondary: 8,
|
||||
primary: 9,
|
||||
@@ -111,7 +32,7 @@ export const ROAD_RANK: Record<RoadClass, number> = {
|
||||
motorway: 11,
|
||||
};
|
||||
|
||||
/** Minor paths vanish first as the camera pulls back. */
|
||||
/** Minor ways vanish first as the camera pulls back. */
|
||||
export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
|
||||
path: 2,
|
||||
steps: 2,
|
||||
@@ -132,38 +53,16 @@ export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
|
||||
motorway: 0,
|
||||
};
|
||||
|
||||
const BUILDING_BASE = 0xdfd7cc;
|
||||
const BUILDING_TALL = 0xc4b8a8;
|
||||
|
||||
export const BUILDING_EDGE_COLOR = 0xbdb2a3;
|
||||
|
||||
const BUILDING_TINTS: Partial<Record<BuildingKind, number>> = {
|
||||
church: 0xd9cfc4,
|
||||
school: 0xdcd6c4,
|
||||
civic: 0xd8d2c6,
|
||||
industrial: 0xd7d1cd,
|
||||
retail: 0xe2d5cc,
|
||||
commercial: 0xe0d6cd,
|
||||
shed: 0xe3ddd4,
|
||||
garage: 0xe3ddd4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
|
||||
* Anything above ~30 m is already at the darkest end of the ramp.
|
||||
* Ways drawn as a dashed line over a plain casing. It is the conventional way to say "this is not a road you
|
||||
* can drive", and it stops footpaths from reading as pale streets.
|
||||
*/
|
||||
export function buildingColor(kind: BuildingKind, height: number): number {
|
||||
const base = BUILDING_TINTS[kind] ?? BUILDING_BASE;
|
||||
return mix(base, BUILDING_TALL, clamp01((height - 3) / 27));
|
||||
const DASHED: ReadonlySet<RoadClass> = new Set<RoadClass>(['footway', 'path', 'steps', 'cycleway']);
|
||||
|
||||
export function isDashed(roadClass: RoadClass): boolean {
|
||||
return DASHED.has(roadClass);
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
function mix(from: number, to: number, t: number): number {
|
||||
const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t);
|
||||
const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t);
|
||||
const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
export function isRailway(roadClass: RoadClass): boolean {
|
||||
return roadClass === 'railway';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { roofColor, THEMES, wallColor, type Theme } from './theme';
|
||||
|
||||
const themes = Object.values(THEMES);
|
||||
|
||||
const luminance = (color: number) =>
|
||||
0.2126 * ((color >> 16) & 0xff) + 0.7152 * ((color >> 8) & 0xff) + 0.0722 * (color & 0xff);
|
||||
|
||||
describe('THEMES', () => {
|
||||
it('names itself the same as the key it is registered under', () => {
|
||||
for (const [name, theme] of Object.entries(THEMES)) {
|
||||
expect(theme.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['areas', 'waterLines', 'roadFill', 'roadCasing'] as const)(
|
||||
'covers exactly the same %s in every theme',
|
||||
(table) => {
|
||||
// A kind added to one palette and forgotten in the other renders as `undefined` — a black hole on the map.
|
||||
const [first, ...rest] = themes;
|
||||
const expected = Object.keys(first![table]).sort();
|
||||
|
||||
for (const theme of rest) {
|
||||
expect(Object.keys(theme[table]).sort()).toEqual(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('gives every colour a real value', () => {
|
||||
for (const theme of themes) {
|
||||
for (const table of ['areas', 'waterLines', 'roadFill', 'roadCasing'] as const) {
|
||||
for (const [kind, color] of Object.entries(theme[table])) {
|
||||
expect(typeof color, `${theme.name}.${table}.${kind}`).toBe('number');
|
||||
expect(color, `${theme.name}.${table}.${kind}`).toBeGreaterThanOrEqual(0);
|
||||
expect(color, `${theme.name}.${table}.${kind}`).toBeLessThanOrEqual(0xffffff);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('separates land from what surrounds it', () => {
|
||||
for (const theme of themes) {
|
||||
expect(theme.land, theme.name).not.toBe(theme.outside);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with itself about being dark', () => {
|
||||
expect(THEMES.day.dark).toBe(false);
|
||||
expect(THEMES.night.dark).toBe(true);
|
||||
expect(luminance(THEMES.night.land)).toBeLessThan(luminance(THEMES.day.land));
|
||||
});
|
||||
|
||||
it('keeps label text legible against its own halo', () => {
|
||||
for (const theme of themes) {
|
||||
const contrast = Math.abs(luminance(theme.label) - luminance(theme.labelHalo));
|
||||
expect(contrast, theme.name).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves room between a tunnel and the surface', () => {
|
||||
for (const theme of themes) {
|
||||
expect(theme.tunnelAlpha, theme.name).toBeGreaterThan(0);
|
||||
expect(theme.tunnelAlpha, theme.name).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('building colours', () => {
|
||||
const shades = (theme: Theme) => [3, 10, 20, 30, 60].map((height) => roofColor(theme, 'house', height));
|
||||
|
||||
it('moves along the height ramp and then settles', () => {
|
||||
for (const theme of themes) {
|
||||
const [low, , , tall, taller] = shades(theme);
|
||||
|
||||
expect(low).not.toBe(tall);
|
||||
// Past the top of the ramp the colour stops changing, so a skyscraper is not a special case.
|
||||
expect(tall).toBe(taller);
|
||||
}
|
||||
});
|
||||
|
||||
it('darkens with height by day and lightens by night', () => {
|
||||
const day = shades(THEMES.day);
|
||||
const night = shades(THEMES.night);
|
||||
|
||||
expect(luminance(day[4]!)).toBeLessThan(luminance(day[0]!));
|
||||
expect(luminance(night[4]!)).toBeGreaterThan(luminance(night[0]!));
|
||||
});
|
||||
|
||||
it('gives a recognised kind its own tint', () => {
|
||||
for (const theme of themes) {
|
||||
expect(roofColor(theme, 'church', 6)).not.toBe(roofColor(theme, 'unknown', 6));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps walls darker than the roof they hold up', () => {
|
||||
for (const theme of themes) {
|
||||
const roof = roofColor(theme, 'house', 12);
|
||||
expect(luminance(wallColor(theme, roof)), theme.name).toBeLessThan(luminance(roof));
|
||||
}
|
||||
});
|
||||
|
||||
it('stays inside the colour space', () => {
|
||||
for (const theme of themes) {
|
||||
for (const height of [0, 1, 5, 50, 500]) {
|
||||
const roof = roofColor(theme, 'apartments', height);
|
||||
expect(roof).toBeGreaterThanOrEqual(0);
|
||||
expect(roof).toBeLessThanOrEqual(0xffffff);
|
||||
expect(Number.isInteger(roof)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types';
|
||||
|
||||
/**
|
||||
* Every colour the map draws lives here. Nothing else in the renderer names a colour, so a new look is a new
|
||||
* entry in {@link THEMES} rather than a sweep through the drawing code.
|
||||
*/
|
||||
export type ThemeName = 'day' | 'night';
|
||||
|
||||
export interface Theme {
|
||||
name: ThemeName;
|
||||
/** True when the surrounding page chrome should switch to its dark variant too. */
|
||||
dark: boolean;
|
||||
|
||||
/** Beyond the generated square. */
|
||||
outside: number;
|
||||
/** The square itself, under all land cover. */
|
||||
land: number;
|
||||
border: number;
|
||||
|
||||
areas: Record<AreaKind, number>;
|
||||
water: number;
|
||||
waterEdge: number;
|
||||
waterLines: Record<WaterKind, number>;
|
||||
|
||||
roadFill: Record<RoadClass, number>;
|
||||
roadCasing: Record<RoadClass, number>;
|
||||
railBed: number;
|
||||
railSleeper: number;
|
||||
/** How much of an underground stretch shows through the surface. */
|
||||
tunnelAlpha: number;
|
||||
|
||||
buildingBase: number;
|
||||
buildingTall: number;
|
||||
buildingEdge: number;
|
||||
buildingTints: Partial<Record<BuildingKind, number>>;
|
||||
/** Walls are the roof colour pulled this far toward {@link buildingWall}. */
|
||||
buildingWall: number;
|
||||
buildingWallMix: number;
|
||||
|
||||
arrow: number;
|
||||
arrowAlpha: number;
|
||||
|
||||
label: number;
|
||||
labelHalo: number;
|
||||
}
|
||||
|
||||
const DAY: Theme = {
|
||||
name: 'day',
|
||||
dark: false,
|
||||
|
||||
outside: 0xd5d9d2,
|
||||
land: 0xeceee7,
|
||||
border: 0xb4bab2,
|
||||
|
||||
areas: {
|
||||
unknown: 0xe6e6e0,
|
||||
forest: 0xc6ddb8,
|
||||
grass: 0xd8e9c6,
|
||||
meadow: 0xdfeecd,
|
||||
farmland: 0xece7c8,
|
||||
orchard: 0xd9e7bd,
|
||||
scrub: 0xd9e6c9,
|
||||
heath: 0xdfe4cb,
|
||||
sand: 0xf0e9cf,
|
||||
bareRock: 0xdedcd5,
|
||||
wetland: 0xd2e2d7,
|
||||
park: 0xd2eac0,
|
||||
garden: 0xd9edc6,
|
||||
pitch: 0xc9e6b4,
|
||||
cemetery: 0xd6e0cd,
|
||||
residentialZone: 0xe6e3de,
|
||||
industrialZone: 0xe3dee2,
|
||||
commercialZone: 0xeae0dd,
|
||||
retailZone: 0xeddfd7,
|
||||
quarry: 0xdfdad2,
|
||||
parking: 0xe7e4dd,
|
||||
school: 0xeae3d7,
|
||||
beach: 0xf4ead1,
|
||||
},
|
||||
|
||||
water: 0x9fc9e0,
|
||||
waterEdge: 0x84b4cf,
|
||||
waterLines: {
|
||||
unknown: 0x9fc9e0,
|
||||
water: 0x9fc9e0,
|
||||
lake: 0x9fc9e0,
|
||||
pond: 0x9fc9e0,
|
||||
reservoir: 0x9fc9e0,
|
||||
riverbank: 0x9fc9e0,
|
||||
river: 0x9fc9e0,
|
||||
stream: 0xa9d0e4,
|
||||
canal: 0x9fc9e0,
|
||||
ditch: 0xb5d7e8,
|
||||
drain: 0xb5d7e8,
|
||||
},
|
||||
|
||||
roadFill: {
|
||||
unknown: 0xf7f6f4,
|
||||
motorway: 0xf3ae68,
|
||||
trunk: 0xf7c489,
|
||||
primary: 0xfad89b,
|
||||
secondary: 0xfae9b8,
|
||||
tertiary: 0xfdfaf0,
|
||||
unclassified: 0xffffff,
|
||||
residential: 0xffffff,
|
||||
livingStreet: 0xf7f7f5,
|
||||
service: 0xfbfbfa,
|
||||
track: 0xe6d9bd,
|
||||
pedestrian: 0xf1eee9,
|
||||
footway: 0xd89a76,
|
||||
cycleway: 0x8fa4cf,
|
||||
steps: 0xd08a68,
|
||||
path: 0xcfa484,
|
||||
railway: 0xb2aca6,
|
||||
},
|
||||
|
||||
roadCasing: {
|
||||
unknown: 0xd8d4cd,
|
||||
motorway: 0xd48f45,
|
||||
trunk: 0xd9a163,
|
||||
primary: 0xdcb872,
|
||||
secondary: 0xdfcb8c,
|
||||
tertiary: 0xd2cec6,
|
||||
unclassified: 0xd2cec6,
|
||||
residential: 0xd2cec6,
|
||||
livingStreet: 0xd2cec6,
|
||||
service: 0xdad6cf,
|
||||
track: 0xc7b795,
|
||||
pedestrian: 0xd6d1c9,
|
||||
footway: 0xf2ece4,
|
||||
cycleway: 0xf2ece4,
|
||||
steps: 0xf2ece4,
|
||||
path: 0xf2ece4,
|
||||
railway: 0x8d8781,
|
||||
},
|
||||
|
||||
railBed: 0x8d8781,
|
||||
railSleeper: 0xf4f2ee,
|
||||
tunnelAlpha: 0.45,
|
||||
|
||||
buildingBase: 0xdfd7cc,
|
||||
buildingTall: 0xc4b8a8,
|
||||
buildingEdge: 0xa89d8d,
|
||||
buildingTints: {
|
||||
church: 0xd9cfc4,
|
||||
school: 0xdcd6c4,
|
||||
civic: 0xd8d2c6,
|
||||
industrial: 0xd7d1cd,
|
||||
retail: 0xe2d5cc,
|
||||
commercial: 0xe0d6cd,
|
||||
shed: 0xe3ddd4,
|
||||
garage: 0xe3ddd4,
|
||||
},
|
||||
buildingWall: 0x6f6455,
|
||||
buildingWallMix: 0.45,
|
||||
|
||||
arrow: 0x6e6a63,
|
||||
arrowAlpha: 0.5,
|
||||
|
||||
label: 0x3a3833,
|
||||
labelHalo: 0xfbfaf7,
|
||||
};
|
||||
|
||||
const NIGHT: Theme = {
|
||||
name: 'night',
|
||||
dark: true,
|
||||
|
||||
outside: 0x0b0e11,
|
||||
land: 0x161b20,
|
||||
border: 0x2c343c,
|
||||
|
||||
areas: {
|
||||
unknown: 0x1a1f24,
|
||||
forest: 0x18291d,
|
||||
grass: 0x1d2e21,
|
||||
meadow: 0x1f3124,
|
||||
farmland: 0x272516,
|
||||
orchard: 0x1c2c1a,
|
||||
scrub: 0x1e2a1f,
|
||||
heath: 0x232719,
|
||||
sand: 0x2c2819,
|
||||
bareRock: 0x22242a,
|
||||
wetland: 0x152a26,
|
||||
park: 0x1a3221,
|
||||
garden: 0x1d3524,
|
||||
pitch: 0x1f3d25,
|
||||
cemetery: 0x1c2621,
|
||||
residentialZone: 0x1b2027,
|
||||
industrialZone: 0x1f1e26,
|
||||
commercialZone: 0x231e24,
|
||||
retailZone: 0x261f20,
|
||||
quarry: 0x232019,
|
||||
parking: 0x1e2026,
|
||||
school: 0x24211b,
|
||||
beach: 0x2e2a1c,
|
||||
},
|
||||
|
||||
water: 0x123243,
|
||||
waterEdge: 0x1c4a60,
|
||||
waterLines: {
|
||||
unknown: 0x123243,
|
||||
water: 0x123243,
|
||||
lake: 0x123243,
|
||||
pond: 0x123243,
|
||||
reservoir: 0x123243,
|
||||
riverbank: 0x123243,
|
||||
river: 0x17415a,
|
||||
stream: 0x1a4a63,
|
||||
canal: 0x17415a,
|
||||
ditch: 0x1c4258,
|
||||
drain: 0x1c4258,
|
||||
},
|
||||
|
||||
roadFill: {
|
||||
unknown: 0x3b424b,
|
||||
motorway: 0xd08a3a,
|
||||
trunk: 0xc07f39,
|
||||
primary: 0xbb9445,
|
||||
secondary: 0x9a8a4c,
|
||||
tertiary: 0x555f6a,
|
||||
unclassified: 0x4a535e,
|
||||
residential: 0x4a535e,
|
||||
livingStreet: 0x454e58,
|
||||
service: 0x3b424b,
|
||||
track: 0x453d2c,
|
||||
pedestrian: 0x424852,
|
||||
footway: 0x6a5344,
|
||||
cycleway: 0x3b4761,
|
||||
steps: 0x6d4f3d,
|
||||
path: 0x5e4c3e,
|
||||
railway: 0x4e545c,
|
||||
},
|
||||
|
||||
roadCasing: {
|
||||
unknown: 0x1e232a,
|
||||
motorway: 0x7a4d17,
|
||||
trunk: 0x6f471b,
|
||||
primary: 0x6b5320,
|
||||
secondary: 0x585027,
|
||||
tertiary: 0x272d35,
|
||||
unclassified: 0x252b33,
|
||||
residential: 0x252b33,
|
||||
livingStreet: 0x252b33,
|
||||
service: 0x21262d,
|
||||
track: 0x272215,
|
||||
pedestrian: 0x242932,
|
||||
footway: 0x1c2027,
|
||||
cycleway: 0x1c2027,
|
||||
steps: 0x1c2027,
|
||||
path: 0x1c2027,
|
||||
railway: 0x2a2f36,
|
||||
},
|
||||
|
||||
railBed: 0x2a2f36,
|
||||
railSleeper: 0x7d848d,
|
||||
tunnelAlpha: 0.4,
|
||||
|
||||
// At night the taller a building is, the more of the sky it catches, so the ramp runs lighter.
|
||||
buildingBase: 0x2b323a,
|
||||
buildingTall: 0x424c59,
|
||||
buildingEdge: 0x4a5563,
|
||||
buildingTints: {
|
||||
church: 0x353040,
|
||||
school: 0x33352a,
|
||||
civic: 0x2f3540,
|
||||
industrial: 0x2e2f36,
|
||||
retail: 0x362d30,
|
||||
commercial: 0x343039,
|
||||
shed: 0x252b32,
|
||||
garage: 0x252b32,
|
||||
},
|
||||
buildingWall: 0x0d1116,
|
||||
buildingWallMix: 0.5,
|
||||
|
||||
arrow: 0x9aa3ad,
|
||||
arrowAlpha: 0.45,
|
||||
|
||||
label: 0xd7dde3,
|
||||
labelHalo: 0x0d1116,
|
||||
};
|
||||
|
||||
export const THEMES: Record<ThemeName, Theme> = { day: DAY, night: NIGHT };
|
||||
|
||||
/**
|
||||
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
|
||||
* Anything above roughly 30 m is already at the far end of the ramp.
|
||||
*/
|
||||
export function roofColor(theme: Theme, kind: BuildingKind, height: number): number {
|
||||
const base = theme.buildingTints[kind] ?? theme.buildingBase;
|
||||
return mix(base, theme.buildingTall, clamp01((height - 3) / 27));
|
||||
}
|
||||
|
||||
/** Walls are the roof pulled toward the theme's shadow colour, which keeps the two obviously related. */
|
||||
export function wallColor(theme: Theme, roof: number): number {
|
||||
return mix(roof, theme.buildingWall, theme.buildingWallMix);
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
function mix(from: number, to: number, t: number): number {
|
||||
const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t);
|
||||
const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t);
|
||||
const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
:root {
|
||||
--panel-bg: rgba(252, 252, 250, 0.94);
|
||||
--panel-border: #d9d6cf;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #f3f5f1;
|
||||
--text: #2b2a27;
|
||||
--text-muted: #75726b;
|
||||
--accent: #3d6b4a;
|
||||
--accent-hover: #325a3d;
|
||||
--error: #a63d33;
|
||||
--page-bg: #d5d9d2;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* The map sets this attribute so the page chrome follows whichever theme the canvas is drawing. */
|
||||
:root[data-theme='night'] {
|
||||
--panel-bg: rgba(20, 25, 30, 0.92);
|
||||
--panel-border: #2c343d;
|
||||
--surface: #1a1f25;
|
||||
--surface-hover: #232a32;
|
||||
--text: #dfe4e9;
|
||||
--text-muted: #8d97a2;
|
||||
--accent: #4d8f66;
|
||||
--accent-hover: #5aa276;
|
||||
--error: #d4796d;
|
||||
--page-bg: #0b0e11;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -20,7 +38,7 @@ body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: #d5d9d2;
|
||||
background: var(--page-bg);
|
||||
}
|
||||
|
||||
#stage {
|
||||
@@ -55,12 +73,37 @@ body {
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel__header h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
@@ -93,7 +136,7 @@ body {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
@@ -158,7 +201,7 @@ body {
|
||||
gap: 4px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -190,7 +233,7 @@ body {
|
||||
}
|
||||
|
||||
.world__open:hover:not(:disabled) {
|
||||
background: #f3f5f1;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.world__name {
|
||||
@@ -216,7 +259,7 @@ body {
|
||||
|
||||
.world__delete:hover {
|
||||
color: var(--error);
|
||||
background: #faf1f0;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
// vitest/config re-exports Vite's defineConfig with the `test` section added to its type.
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
/**
|
||||
* Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
|
||||
@@ -36,5 +37,11 @@ export default defineConfig(() => {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
test: {
|
||||
// Everything under test is pure geometry and rule tables, so no DOM is needed. Modules that touch
|
||||
// PixiJS are deliberately kept out of these files.
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,10 +55,11 @@ public class ImportPipelineTests
|
||||
[Fact]
|
||||
public void Imports_a_highway_as_a_road_with_a_width()
|
||||
{
|
||||
// Kept inside a single chunk so this test is about the attributes, not the splitting.
|
||||
using var world = Build(WayWithGeometry(
|
||||
id: 7,
|
||||
tags: new() { ["highway"] = "residential", ["name"] = "Main Street" },
|
||||
metres: [new Vector2(-200, 0), new Vector2(200, 0)]));
|
||||
metres: [new Vector2(100, 0), new Vector2(300, 0)]));
|
||||
|
||||
var road = Assert.Single(Export(world)[0].Chunk.Roads);
|
||||
|
||||
@@ -76,12 +77,50 @@ public class ImportPipelineTests
|
||||
tags: new() { ["highway"] = "primary" },
|
||||
metres: [new Vector2(0, 0), new Vector2(50_000, 0)]));
|
||||
|
||||
var road = Assert.Single(Export(world)[0].Chunk.Roads);
|
||||
var maxX = road.Path.Where((_, index) => index % 2 == 0).Max();
|
||||
var maxX = Export(world)
|
||||
.SelectMany(chunk => chunk.Chunk.Roads)
|
||||
.SelectMany(road => road.Path.Where((_, index) => index % 2 == 0))
|
||||
.Max();
|
||||
|
||||
Assert.True(maxX <= WorldSizeMeters / 2 + 0.5, $"Road reached {maxX} m, past the world edge.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splits_a_long_road_into_one_piece_per_chunk_it_crosses()
|
||||
{
|
||||
// Spans the full width of the world at y = 0, which crosses all four chunk columns of row 2.
|
||||
using var world = Build(WayWithGeometry(
|
||||
id: 12,
|
||||
tags: new() { ["highway"] = "primary" },
|
||||
metres: [new Vector2(-900, 0), new Vector2(900, 0)]));
|
||||
|
||||
var chunks = Export(world);
|
||||
|
||||
Assert.Equal(4, chunks.Count);
|
||||
Assert.All(chunks, chunk => Assert.Equal(2, chunk.Coord.Y));
|
||||
Assert.Equal([0, 1, 2, 3], chunks.Select(chunk => chunk.Coord.X));
|
||||
|
||||
// Every piece now sits inside its own chunk, so the chunk's bounds no longer stretch across the map.
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
var square = world.Grid.BoundsOf(chunk.Coord);
|
||||
Assert.True(chunk.Index.Bounds[0] >= square.MinX - 2f);
|
||||
Assert.True(chunk.Index.Bounds[2] <= square.MaxX + 2f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_a_short_road_whole()
|
||||
{
|
||||
using var world = Build(WayWithGeometry(
|
||||
id: 13,
|
||||
tags: new() { ["highway"] = "service" },
|
||||
metres: [new Vector2(10, 10), new Vector2(60, 40)]));
|
||||
|
||||
Assert.Single(Export(world));
|
||||
Assert.Single(Export(world)[0].Chunk.Roads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assembles_a_multipolygon_relation_and_keeps_its_holes()
|
||||
{
|
||||
@@ -147,13 +186,13 @@ public class ImportPipelineTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chunk_bounds_stretch_to_cover_geometry_that_overhangs()
|
||||
public void Chunk_bounds_stretch_to_cover_areas_that_overhang()
|
||||
{
|
||||
// A road whose centre sits in one chunk but which reaches well into its neighbours.
|
||||
// Areas are still bucketed whole by the centre of their extent, so a large one overhangs its chunk.
|
||||
using var world = Build(WayWithGeometry(
|
||||
id: 12,
|
||||
tags: new() { ["highway"] = "primary" },
|
||||
metres: [new Vector2(-900, 0), new Vector2(900, 0)]));
|
||||
id: 14,
|
||||
tags: new() { ["landuse"] = "forest" },
|
||||
metres: Square(0, 0, 1500, closed: true)));
|
||||
|
||||
var exported = Assert.Single(Export(world));
|
||||
var square = world.Grid.BoundsOf(exported.Coord);
|
||||
|
||||
Reference in New Issue
Block a user