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:
Leonid Pershin
2026-08-16 18:37:25 +03:00
parent 8460921bfa
commit 312d6bc58a
24 changed files with 2971 additions and 314 deletions
+55 -9
View File
@@ -46,12 +46,21 @@ dotnet run --project src/TheLivingWorld.Api
npm --prefix src/TheLivingWorld.Web run dev npm --prefix src/TheLivingWorld.Web run dev
``` ```
Tests: Tests — the backend under xUnit, the client under Vitest:
```bash ```bash
dotnet test 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 ## How a world is made
1. **Fetch.** `OverpassClient` posts one bounding-box query to Overpass and streams the response into 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 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 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. 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 4. **Systems.** `ComputeBoundsSystem` fills each entity's extent, `AssignChunksSystem` buckets it into the
chunk grid. chunk grid.
5. **Export.** `ChunkExporter` walks the ECS world and writes one JSON file per chunk, plus an index. 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 ## The client
`MapView` owns one scaled container holding five layers — land cover, water, road casings, road fills, `MapView` owns one scaled container holding the layer stack from `layers.ts`, plus a screen-space layer for
buildings — so a building in one chunk never ends up under a park from the next. `Camera` is the only place place names above it. `Camera` is the only place the Y flip lives; everything else thinks in map coordinates.
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 `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 view, keeping the parsed data cached so panning back is instant. When a chunk is redrawn — a zoom step or a
disappear first, then small buildings, and stroke widths gain a floor so hairlines stay visible. 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 ## 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 - 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. 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 - Lines are split at chunk boundaries, but polygons are not: a large forest or landuse block still belongs
overhangs its chunk. The exported chunk bounds are widened to match and the client culls against those. whole to the chunk holding the centre of its extent and overhangs its neighbours. The exported chunk bounds
- Buildings are flat footprints shaded by height. No 3D, no roofs. 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> /// <summary>Rings smaller than this are noise at any zoom the client offers.</summary>
private const float MinimumAreaSquareMeters = 1.0f; 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) public WorldStats Populate(GameWorld world, IEnumerable<OverpassElement> elements)
{ {
var half = (float)(world.Metadata.SizeMeters / 2.0); 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, GameWorld world, OsmSource source, Road road, Vector2[] points,
string? name, in RectBounds clip, Counters counters) 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( world.Ecs.Create(
source, source,
road, road,
new Polyline(world.Shapes.Add(run)), new Polyline(world.Shapes.Add(piece)),
new Bounds(), new Bounds(),
new InChunk(), new InChunk(),
new DisplayName(name)); new DisplayName(name));
@@ -224,12 +230,12 @@ public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
GameWorld world, OsmSource source, Water water, Vector2[] points, GameWorld world, OsmSource source, Water water, Vector2[] points,
string? name, in RectBounds clip, Counters counters) 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( world.Ecs.Create(
source, source,
water, water,
new Polyline(world.Shapes.Add(run)), new Polyline(world.Shapes.Add(piece)),
new Bounds(), new Bounds(),
new InChunk(), new InChunk(),
new DisplayName(name)); 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) private static int[]? StoreHoles(GameWorld world, List<Vector2[]>? holes, in RectBounds clip)
{ {
if (holes is null || holes.Count == 0) return null; if (holes is null || holes.Count == 0) return null;
+7 -2
View File
@@ -11,8 +11,13 @@
<aside id="panel" class="panel"> <aside id="panel" class="panel">
<header class="panel__header"> <header class="panel__header">
<h1>The Living World</h1> <div class="panel__titles">
<p class="panel__subtitle">Generate a world from OpenStreetMap</p> <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> </header>
<form id="generate-form" class="form"> <form id="generate-form" class="form">
+394 -1
View File
@@ -13,9 +13,17 @@
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"typescript": "^5.9.3", "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": { "node_modules/@oxc-project/types": {
"version": "0.144.0", "version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -277,12 +285,44 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/earcut": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "26.2.0", "version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
@@ -293,6 +333,119 @@
"undici-types": "~8.3.0" "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": { "node_modules/@webgpu/types": {
"version": "0.1.71", "version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
@@ -308,6 +461,33 @@
"node": ">=10.0.0" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -324,12 +504,39 @@
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC" "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": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -645,6 +852,16 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "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": "^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": { "node_modules/parse-svg-path": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -778,6 +1016,13 @@
"@rolldown/binding-win32-x64-msvc": "1.2.4" "@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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -788,6 +1033,20 @@
"node": ">=0.10.0" "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": { "node_modules/tiny-lru": {
"version": "11.4.7", "version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
@@ -797,6 +1056,23 @@
"node": ">=12" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -814,6 +1090,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -912,6 +1198,113 @@
"optional": true "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"
}
} }
} }
} }
+5 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"pixi.js": "^8.19.0" "pixi.js": "^8.19.0"
@@ -15,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.2.1" "vite": "^8.2.1",
"vitest": "^4.1.10"
} }
} }
+23
View File
@@ -2,8 +2,10 @@ import './styles.css';
import { api, waitForWorld } from './api/client'; import { api, waitForWorld } from './api/client';
import type { WorldSummary } from './api/types'; import type { WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView'; import { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme';
const LAST_WORLD_KEY = 'the-living-world:last-world'; const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme';
const elements = { const elements = {
stage: required<HTMLDivElement>('stage'), stage: required<HTMLDivElement>('stage'),
@@ -17,6 +19,7 @@ const elements = {
worldList: required<HTMLUListElement>('world-list'), worldList: required<HTMLUListElement>('world-list'),
status: required<HTMLElement>('status'), status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'), hud: required<HTMLDivElement>('hud'),
themeToggle: required<HTMLButtonElement>('theme-toggle'),
}; };
const view = new MapView(); const view = new MapView();
@@ -176,6 +179,21 @@ function message(error: unknown): string {
return error instanceof Error ? error.message : String(error); 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> { async function start(): Promise<void> {
elements.size.addEventListener('input', () => { elements.size.addEventListener('input', () => {
elements.sizeValue.textContent = `${elements.size.value} km`; elements.sizeValue.textContent = `${elements.size.value} km`;
@@ -184,8 +202,13 @@ async function start(): Promise<void> {
void generate(event); void generate(event);
}); });
elements.themeToggle.addEventListener('click', () => {
applyTheme(view.themeName === 'day' ? 'night' : 'day');
});
view.onStatusChange = renderHud; view.onStatusChange = renderHud;
await view.init(elements.stage); await view.init(elements.stage);
applyTheme(readStoredTheme());
try { try {
const worlds = await refreshWorldList(); 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);
});
});
+7
View File
@@ -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. */ /** The world rectangle currently on screen, optionally grown by a fraction of its size. */
visibleRect(viewport: Viewport, padding = 0): WorldRect { visibleRect(viewport: Viewport, padding = 0): WorldRect {
const halfWidth = viewport.width / 2 / this.zoom; const halfWidth = viewport.width / 2 / this.zoom;
+111 -38
View File
@@ -1,22 +1,18 @@
import { Container } from 'pixi.js';
import { api } from '../api/client'; import { api } from '../api/client';
import type { ChunkIndexEntry, MapChunk } from '../api/types'; import type { ChunkIndexEntry, MapChunk } from '../api/types';
import { rectsIntersect, type WorldRect } from './camera'; import { rectsIntersect, type WorldRect } from './camera';
import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer'; import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer';
import type { MapLayers } from './layers';
export interface MapLayers {
areas: Container;
water: Container;
roadCasings: Container;
roads: Container;
buildings: Container;
}
interface ChunkState { interface ChunkState {
entry: ChunkIndexEntry; entry: ChunkIndexEntry;
rect: WorldRect; rect: WorldRect;
data?: MapChunk; data?: MapChunk;
graphics?: ChunkGraphics; 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. */ /** Profile the current graphics were built for; a mismatch means they need redrawing. */
drawnWith?: string; drawnWith?: string;
request?: AbortController; request?: AbortController;
@@ -31,6 +27,21 @@ const RETAIN_PADDING = 1.0;
const MAX_CONCURRENT_REQUESTS = 6; 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 * 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, * 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 worldId: string | null = null;
private inFlight = 0; private inFlight = 0;
private pending: ChunkState[] = []; private pending: ChunkState[] = [];
private drawQueue: ChunkState[] = [];
constructor(private readonly layers: MapLayers) {} constructor(private readonly layers: MapLayers) {}
@@ -55,7 +67,7 @@ export class ChunkManager {
} }
get isBusy(): boolean { 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 { setWorld(worldId: string, index: ChunkIndexEntry[]): void {
@@ -67,6 +79,7 @@ export class ChunkManager {
this.states.set(key(entry.x, entry.y), { this.states.set(key(entry.x, entry.y), {
entry, entry,
rect: { minX, minY, maxX, maxY }, rect: { minX, minY, maxX, maxY },
fade: 1,
}); });
} }
} }
@@ -74,18 +87,24 @@ export class ChunkManager {
clear(): void { clear(): void {
for (const state of this.states.values()) { for (const state of this.states.values()) {
state.request?.abort(); state.request?.abort();
if (state.graphics) { this.discard(state.graphics);
detach(state.graphics); this.discard(state.outgoing);
destroyChunkGraphics(state.graphics);
}
} }
this.states.clear(); this.states.clear();
this.pending = []; this.pending = [];
this.drawQueue = [];
this.inFlight = 0; this.inFlight = 0;
this.worldId = null; 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. */ /** Reconciles what is on screen with what should be, given the camera's current view and zoom. */
update(visible: WorldRect, profile: RenderProfile): void { update(visible: WorldRect, profile: RenderProfile): void {
if (!this.worldId) return; if (!this.worldId) return;
@@ -94,6 +113,7 @@ export class ChunkManager {
const retain = grow(visible, RETAIN_PADDING); const retain = grow(visible, RETAIN_PADDING);
this.pending = []; this.pending = [];
this.drawQueue = [];
for (const state of this.states.values()) { for (const state of this.states.values()) {
const wanted = rectsIntersect(state.rect, prefetch); const wanted = rectsIntersect(state.rect, prefetch);
@@ -105,24 +125,73 @@ export class ChunkManager {
if (!state.data) continue; if (!state.data) continue;
if (wanted) { if (!wanted) {
if (!state.graphics || state.drawnWith !== profile.key) this.draw(state, profile); if (state.graphics && !rectsIntersect(state.rect, retain)) {
} else if (state.graphics && !rectsIntersect(state.rect, retain)) { this.discard(state.graphics);
detach(state.graphics); this.discard(state.outgoing);
destroyChunkGraphics(state.graphics); state.graphics = undefined;
state.graphics = undefined; state.outgoing = undefined;
state.drawnWith = undefined; state.drawnWith = undefined;
}
continue;
}
// 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 chunks first: the middle of the screen is what the player is looking at. // 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 centerX = (visible.minX + visible.maxX) / 2;
const centerY = (visible.minY + visible.maxY) / 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); 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 { private pump(profile: RenderProfile): void {
while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) { while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) {
const state = this.pending.shift()!; const state = this.pending.shift()!;
@@ -145,7 +214,9 @@ export class ChunkManager {
if (this.worldId !== worldId) return; if (this.worldId !== worldId) return;
state.data = chunk; 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) { } catch (error) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
state.failed = true; state.failed = true;
@@ -161,26 +232,28 @@ export class ChunkManager {
private draw(state: ChunkState, profile: RenderProfile): void { private draw(state: ChunkState, profile: RenderProfile): void {
if (!state.data) return; if (!state.data) return;
if (state.graphics) { // A redraw arriving mid-dissolve replaces whatever was still fading; only one set may be outgoing.
detach(state.graphics); this.discard(state.outgoing);
destroyChunkGraphics(state.graphics); state.outgoing = state.graphics;
}
const graphics = renderChunk(state.data, profile); const graphics = renderChunk(state.data, profile);
state.graphics = graphics; state.graphics = graphics;
state.drawnWith = profile.key; state.drawnWith = profile.key;
this.layers.areas.addChild(graphics.areas); // A chunk appearing for the first time has nothing to dissolve from, so it simply appears.
this.layers.water.addChild(graphics.water); state.fade = state.outgoing ? 0 : 1;
this.layers.roadCasings.addChild(graphics.roadCasings);
this.layers.roads.addChild(graphics.roads);
this.layers.buildings.addChild(graphics.buildings);
}
}
function detach(graphics: ChunkGraphics): void { for (const part of graphics) {
for (const layer of Object.values(graphics)) { part.graphics.alpha = state.fade;
layer.removeFromParent(); this.layers[part.layer].addChild(part.graphics);
}
}
private discard(graphics: ChunkGraphics | undefined): void {
if (!graphics) return;
for (const part of graphics) part.graphics.removeFromParent();
destroyChunkGraphics(graphics);
} }
} }
+305 -88
View File
@@ -1,27 +1,35 @@
import { Graphics } from 'pixi.js'; import { Graphics } from 'pixi.js';
import type { AreaFeature, BuildingFeature, FlatPoints, MapChunk, RoadFeature, WaterFeature } from '../api/types';
import { import {
AREA_COLORS, RoadFlags,
BUILDING_EDGE_COLOR, type AreaFeature,
ROAD_CASING_COLORS, type BuildingFeature,
ROAD_COLORS, type FlatPoints,
ROAD_MIN_DETAIL, type MapChunk,
ROAD_RANK, type RoadFeature,
WATER_COLOR, type WaterFeature,
WATER_EDGE_COLOR, } from '../api/types';
WATER_LINE_COLORS, import {
buildingColor, boundingArea,
} from './style'; 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. */ /** 0 = whole town in view, 1 = neighbourhood, 2 = street level. */
export type DetailLevel = 0 | 1 | 2; export type DetailLevel = 0 | 1 | 2;
export interface RenderProfile { 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; key: string;
detail: DetailLevel; detail: DetailLevel;
/** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */ /** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */
minStrokeMeters: number; minStrokeMeters: number;
theme: Theme;
} }
/** Strokes thinner than this fade into the background. */ /** 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 }; 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; 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. // 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 step = Math.floor(Math.log2(zoom));
const minStrokeMeters = MIN_STROKE_PIXELS / 2 ** step; 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 { export interface ChunkPart {
areas: Graphics; layer: LayerId;
water: Graphics; graphics: Graphics;
roadCasings: Graphics;
roads: Graphics;
buildings: Graphics;
} }
export type ChunkGraphics = ChunkPart[];
export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics { export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics {
return { const painter = new Painter();
areas: renderAreas(chunk.areas),
water: renderWater(chunk.water, profile), renderAreas(painter, chunk.areas, profile);
roadCasings: renderRoads(chunk.roads, profile, true), renderWater(painter, chunk.water, profile);
roads: renderRoads(chunk.roads, profile, false), renderRoads(painter, chunk.roads, profile);
buildings: renderBuildings(chunk.buildings, profile), renderBuildings(painter, chunk.buildings, profile);
};
return painter.parts();
} }
export function destroyChunkGraphics(graphics: ChunkGraphics): void { export function destroyChunkGraphics(graphics: ChunkGraphics): void {
for (const layer of Object.values(graphics)) { for (const part of graphics) {
layer.destroy(); 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; color: number;
} }
function renderAreas(areas: AreaFeature[]): Graphics { function renderAreas(painter: Painter, areas: AreaFeature[], profile: RenderProfile): void {
const g = new Graphics(); // Big blocks first, so the parks and pitches sitting inside them stay visible.
// Big landuse blocks first, so the parks and pitches sitting inside them stay visible.
const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline)); const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline));
forEachRun( forEachRun(
sorted, sorted,
(area) => AREA_COLORS[area.kind] ?? AREA_COLORS.unknown, (area) => `${areaLayer(area.kind)}|${area.kind}`,
(run, color) => fillRings(g, run, color), (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 { function renderWater(painter: Painter, water: WaterFeature[], profile: RenderProfile): void {
const g = new Graphics(); if (water.length === 0) return;
const { theme } = profile;
const surface = painter.on('water');
const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline); 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) { for (const stream of water) {
if (!stream.path) continue; 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), 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', cap: 'round',
join: 'round', join: 'round',
}); });
} }
return g;
} }
function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics { function renderRoads(painter: Painter, roads: RoadFeature[], profile: RenderProfile): void {
const g = new Graphics(); const visible = roads.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail);
if (visible.length === 0) return;
const visible = roads const byBand = new Map<RoadBand, RoadFeature[]>();
.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail) for (const road of visible) {
.sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width); 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. // 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); 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( forEachRun(
visible, sorted,
(road) => `${road.class}:${road.width}`, (road) => `${road.class}:${road.width}`,
(run) => { (run) => {
const first = run[0]!; const first = run[0]!;
for (const road of run) g.poly(road.path, false);
const width = Math.max(first.width, profile.minStrokeMeters); 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({ g.stroke({
width: casing ? width + casingMargin : width, 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', cap: 'round',
join: 'round', join: 'round',
}); });
}, },
); );
return g;
} }
function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics { /** Footways, paths, steps and cycleways: a dashed line so they never read as a pale street. */
const g = new Graphics(); 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 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 = 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. // Footprints never overlap, so they can be regrouped by colour without disturbing the picture.
const byColor = new Map<number, BuildingFeature[]>(); const byColor = new Map<number, BuildingFeature[]>();
for (const building of buildings) { for (const building of visible) {
if (minArea > 0 && boundingArea(building.outline) < minArea) continue; const color = roofColor(theme, building.kind, building.height);
const color = buildingColor(building.kind, building.height);
const group = byColor.get(color); const group = byColor.get(color);
if (group) group.push(building); if (group) group.push(building);
else byColor.set(color, [building]); else byColor.set(color, [building]);
} }
for (const [color, group] of byColor) { if (extrude) {
fillRings(g, group, color, edge); const walls = painter.on('buildingWalls');
for (const [color, group] of byColor) {
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. */ /** 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 { function forEachRun<T, K>(items: T[], keyOf: (item: T) => K, draw: (run: T[], key: K) => void): void {
let index = 0; 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);
});
});
+268
View File
@@ -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]);
}
}
}
});
});
+94
View File
@@ -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';
}
}
+83 -35
View File
@@ -1,12 +1,26 @@
import { Application, Container, Graphics } from 'pixi.js'; import { Application, Container, Graphics } from 'pixi.js';
import type { WorldMap } from '../api/types'; import type { WorldMap } from '../api/types';
import { Camera, type Viewport } from './camera'; import { Camera, type Viewport } from './camera';
import { ChunkManager, type MapLayers } from './chunkManager'; import { ChunkManager } from './chunkManager';
import { profileForZoom, type RenderProfile } from './chunkRenderer'; 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. */ /** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
const CHUNK_UPDATE_INTERVAL_MS = 90; 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 * The PixiJS side of the map: one scaled container holding the layer stack, a screen-space layer for place
* pointer handling that lets the player move around. * names above it, a camera driving both, and the pointer handling that lets the player move around.
*/ */
export class MapView { export class MapView {
private readonly app = new Application(); private readonly app = new Application();
@@ -30,20 +44,15 @@ export class MapView {
private readonly background = new Graphics(); private readonly background = new Graphics();
private readonly border = new Graphics(); private readonly border = new Graphics();
private readonly camera = new Camera(); private readonly camera = new Camera();
private readonly layers: MapLayers = createLayers();
private readonly layers: MapLayers = {
areas: new Container(),
water: new Container(),
roadCasings: new Container(),
roads: new Container(),
buildings: new Container(),
};
private readonly chunks = new ChunkManager(this.layers); 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 host: HTMLElement | null = null;
private worldSizeMeters = 0; private worldSizeMeters = 0;
private profile: RenderProfile = profileForZoom(0.1); private profile: RenderProfile = profileForZoom(0.1, this.theme);
private cameraDirty = true; private cameraDirty = true;
private lastChunkUpdate = 0; private lastChunkUpdate = 0;
private readonly activePointers = new Map<number, { x: number; y: number }>(); private readonly activePointers = new Map<number, { x: number; y: number }>();
@@ -55,7 +64,7 @@ export class MapView {
this.host = host; this.host = host;
await this.app.init({ await this.app.init({
background: OUTSIDE_COLOR, background: this.theme.outside,
antialias: true, antialias: true,
resizeTo: host, resizeTo: host,
resolution: window.devicePixelRatio || 1, resolution: window.devicePixelRatio || 1,
@@ -65,18 +74,15 @@ export class MapView {
host.appendChild(this.app.canvas); 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.root.addChild( for (const id of LAYER_ORDER) this.root.addChild(this.layers[id]);
this.background, this.root.addChild(this.border);
this.layers.areas,
this.layers.water,
this.layers.roadCasings,
this.layers.roads,
this.layers.buildings,
this.border,
);
this.app.stage.addChild(this.root); 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); this.attachInput(this.app.canvas);
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow. // 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; this.cameraDirty = true;
}).observe(host); }).observe(host);
this.app.ticker.add(() => this.tick()); this.app.ticker.add((ticker) => this.tick(ticker.deltaMS));
} }
showWorld(map: WorldMap): void { showWorld(map: WorldMap): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear();
this.worldSizeMeters = map.sizeMeters; this.worldSizeMeters = map.sizeMeters;
const half = map.sizeMeters / 2; this.paintBackground();
this.background.clear().rect(-half, -half, map.sizeMeters, map.sizeMeters).fill({ color: LAND_COLOR });
this.camera.fit(map.sizeMeters, this.viewport); this.camera.fit(map.sizeMeters, this.viewport);
this.chunks.setWorld(map.id, map.chunks); this.chunks.setWorld(map.id, map.chunks);
this.cameraDirty = true; this.cameraDirty = true;
this.lastChunkUpdate = 0; 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 { clear(): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear();
this.background.clear(); this.background.clear();
this.border.clear(); this.border.clear();
this.worldSizeMeters = 0; this.worldSizeMeters = 0;
@@ -109,6 +135,7 @@ export class MapView {
destroy(): void { destroy(): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear();
this.app.destroy(true, { children: true }); 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; if (this.worldSizeMeters === 0) return;
const viewport = this.viewport; const viewport = this.viewport;
@@ -131,15 +168,26 @@ export class MapView {
this.root.position.set(position.x, position.y); this.root.position.set(position.x, position.y);
this.root.scale.set(this.camera.zoom, -this.camera.zoom); 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.drawBorder();
this.cameraDirty = false; 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(); const now = performance.now();
if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) { if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) {
this.lastChunkUpdate = now; 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(); this.publishStatus();
} }
} }
@@ -149,7 +197,7 @@ export class MapView {
this.border this.border
.clear() .clear()
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters) .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 { private publishStatus(): void {
+20 -121
View File
@@ -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 * Structural drawing rules that hold whatever the palette is: which roads outrank which, when each stops
* renderer draws gets its colour from here so the map reads as one coherent style. * being worth drawing, and which ones are conventionally dashed. Colours live in `theme.ts`.
*/ */
export const LAND_COLOR = 0xeceee7; /**
* Draw order within the road network: bigger roads sit on top of what feeds them.
export const AREA_COLORS: Record<AreaKind, number> = { *
unknown: 0xe6e6e0, * An unrecognised `highway` value ranks low on purpose. It could be anything — a proposed alignment, a
forest: 0xc6ddb8, * raceway, a tag nobody has taught this importer — so it is drawn under the streets we do understand rather
grass: 0xd8e9c6, * than over them.
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. */
export const ROAD_RANK: Record<RoadClass, number> = { export const ROAD_RANK: Record<RoadClass, number> = {
path: 0, path: 0,
steps: 0, steps: 0,
footway: 1, footway: 1,
cycleway: 1, cycleway: 1,
track: 2, track: 2,
unknown: 2,
service: 3, service: 3,
pedestrian: 3, pedestrian: 3,
railway: 4, railway: 4,
livingStreet: 5, livingStreet: 5,
residential: 6, residential: 6,
unclassified: 6, unclassified: 6,
unknown: 6,
tertiary: 7, tertiary: 7,
secondary: 8, secondary: 8,
primary: 9, primary: 9,
@@ -111,7 +32,7 @@ export const ROAD_RANK: Record<RoadClass, number> = {
motorway: 11, 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> = { export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
path: 2, path: 2,
steps: 2, steps: 2,
@@ -132,38 +53,16 @@ export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
motorway: 0, 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. * Ways drawn as a dashed line over a plain casing. It is the conventional way to say "this is not a road you
* Anything above ~30 m is already at the darkest end of the ramp. * can drive", and it stops footpaths from reading as pale streets.
*/ */
export function buildingColor(kind: BuildingKind, height: number): number { const DASHED: ReadonlySet<RoadClass> = new Set<RoadClass>(['footway', 'path', 'steps', 'cycleway']);
const base = BUILDING_TINTS[kind] ?? BUILDING_BASE;
return mix(base, BUILDING_TALL, clamp01((height - 3) / 27)); export function isDashed(roadClass: RoadClass): boolean {
return DASHED.has(roadClass);
} }
function clamp01(value: number): number { export function isRailway(roadClass: RoadClass): boolean {
return value < 0 ? 0 : value > 1 ? 1 : value; return roadClass === 'railway';
}
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;
} }
@@ -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);
}
}
});
});
+307
View File
@@ -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;
}
+48 -5
View File
@@ -1,15 +1,33 @@
:root { :root {
--panel-bg: rgba(252, 252, 250, 0.94); --panel-bg: rgba(252, 252, 250, 0.94);
--panel-border: #d9d6cf; --panel-border: #d9d6cf;
--surface: #ffffff;
--surface-hover: #f3f5f1;
--text: #2b2a27; --text: #2b2a27;
--text-muted: #75726b; --text-muted: #75726b;
--accent: #3d6b4a; --accent: #3d6b4a;
--accent-hover: #325a3d; --accent-hover: #325a3d;
--error: #a63d33; --error: #a63d33;
--page-bg: #d5d9d2;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color-scheme: light; 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; box-sizing: border-box;
} }
@@ -20,7 +38,7 @@ body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
color: var(--text); color: var(--text);
background: #d5d9d2; background: var(--page-bg);
} }
#stage { #stage {
@@ -55,12 +73,37 @@ body {
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.panel__header h1 { .panel__header h1 {
margin: 0; margin: 0;
font-size: 17px; font-size: 17px;
letter-spacing: 0.01em; 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 { .panel__subtitle {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
@@ -93,7 +136,7 @@ body {
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
color: var(--text); color: var(--text);
background: #fff; background: var(--surface);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 7px; border-radius: 7px;
} }
@@ -158,7 +201,7 @@ body {
gap: 4px; gap: 4px;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
overflow: hidden; overflow: hidden;
} }
@@ -190,7 +233,7 @@ body {
} }
.world__open:hover:not(:disabled) { .world__open:hover:not(:disabled) {
background: #f3f5f1; background: var(--surface-hover);
} }
.world__name { .world__name {
@@ -216,7 +259,7 @@ body {
.world__delete:hover { .world__delete:hover {
color: var(--error); color: var(--error);
background: #faf1f0; background: var(--surface-hover);
} }
.status { .status {
+8 -1
View File
@@ -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 * Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
@@ -36,5 +37,11 @@ export default defineConfig(() => {
target: 'es2022', target: 'es2022',
sourcemap: true, 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] [Fact]
public void Imports_a_highway_as_a_road_with_a_width() 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( using var world = Build(WayWithGeometry(
id: 7, id: 7,
tags: new() { ["highway"] = "residential", ["name"] = "Main Street" }, 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); var road = Assert.Single(Export(world)[0].Chunk.Roads);
@@ -76,12 +77,50 @@ public class ImportPipelineTests
tags: new() { ["highway"] = "primary" }, tags: new() { ["highway"] = "primary" },
metres: [new Vector2(0, 0), new Vector2(50_000, 0)])); metres: [new Vector2(0, 0), new Vector2(50_000, 0)]));
var road = Assert.Single(Export(world)[0].Chunk.Roads); var maxX = Export(world)
var maxX = road.Path.Where((_, index) => index % 2 == 0).Max(); .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."); 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] [Fact]
public void Assembles_a_multipolygon_relation_and_keeps_its_holes() public void Assembles_a_multipolygon_relation_and_keeps_its_holes()
{ {
@@ -147,13 +186,13 @@ public class ImportPipelineTests
} }
[Fact] [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( using var world = Build(WayWithGeometry(
id: 12, id: 14,
tags: new() { ["highway"] = "primary" }, tags: new() { ["landuse"] = "forest" },
metres: [new Vector2(-900, 0), new Vector2(900, 0)])); metres: Square(0, 0, 1500, closed: true)));
var exported = Assert.Single(Export(world)); var exported = Assert.Single(Export(world));
var square = world.Grid.BoundsOf(exported.Coord); var square = world.Grid.BoundsOf(exported.Coord);