Enhance README with detailed testing instructions and clarify world generation process; implement chunk boundary handling in OsmWorldBuilder; integrate Vitest for testing in the web project; add theme toggle functionality and improve styling in the web interface.
This commit is contained in:
@@ -46,12 +46,21 @@ dotnet run --project src/TheLivingWorld.Api
|
|||||||
npm --prefix src/TheLivingWorld.Web run dev
|
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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Generated
+394
-1
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import type { FlatPoints } from '../api/types';
|
||||||
|
|
||||||
|
/** Everything here works on flat `[x0, y0, x1, y1, …]` arrays, the format geometry arrives in. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling on dashes per polyline. A chunk-sized road at street zoom needs a few hundred; anything asking for
|
||||||
|
* thousands is a bad dash length, and drawing nothing is far better than exhausting memory.
|
||||||
|
*/
|
||||||
|
const MAX_DASHES_PER_LINE = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts a polyline into dash-length pieces, carrying the pattern across corners so the dashes stay evenly
|
||||||
|
* spaced along the whole line.
|
||||||
|
*
|
||||||
|
* The cursor is carried explicitly rather than recovered from `distance % period` each step. That modulo can
|
||||||
|
* land a hair below the end of a dash, leaving a step of about 1e-16 — and adding that to a position of a few
|
||||||
|
* hundred metres does not change a float64 at all, so the loop spins forever and the output array grows until
|
||||||
|
* the tab runs out of memory. Here every step is either a whole remaining dash or gap, or the rest of the
|
||||||
|
* segment, and the loop leaves the segment the moment it reaches the end.
|
||||||
|
*/
|
||||||
|
export function dashPolyline(points: FlatPoints, dash: number, gap: number): FlatPoints[] {
|
||||||
|
const pieces: FlatPoints[] = [];
|
||||||
|
if (!(dash > 0) || !(gap > 0) || points.length < 4) return pieces;
|
||||||
|
|
||||||
|
// A dash far too small for the line would produce millions of pieces. No caller asks for that today, but
|
||||||
|
// the cost of getting it wrong is the whole tab, so the ceiling is enforced here rather than trusted.
|
||||||
|
if (polylineLength(points) / (dash + gap) > MAX_DASHES_PER_LINE) return pieces;
|
||||||
|
|
||||||
|
let inDash = true;
|
||||||
|
let remaining = dash;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
|
const x0 = points[i]!;
|
||||||
|
const y0 = points[i + 1]!;
|
||||||
|
const x1 = points[i + 2]!;
|
||||||
|
const y1 = points[i + 3]!;
|
||||||
|
|
||||||
|
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||||
|
if (!(length > 0)) continue;
|
||||||
|
|
||||||
|
let position = 0;
|
||||||
|
while (position < length) {
|
||||||
|
const step = Math.min(remaining, length - position);
|
||||||
|
const to = position + step;
|
||||||
|
|
||||||
|
if (inDash) {
|
||||||
|
const from = position / length;
|
||||||
|
const until = to / length;
|
||||||
|
pieces.push([
|
||||||
|
x0 + (x1 - x0) * from,
|
||||||
|
y0 + (y1 - y0) * from,
|
||||||
|
x0 + (x1 - x0) * until,
|
||||||
|
y0 + (y1 - y0) * until,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= step;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
inDash = !inDash;
|
||||||
|
remaining = inDash ? dash : gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either the segment is spent or the cursor moved; both end the loop rather than risk standing still.
|
||||||
|
if (to >= length || to <= position) break;
|
||||||
|
position = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds off gentle bends with Chaikin corner cutting, which converges on a quadratic B-spline while keeping
|
||||||
|
* both ends pinned — so pieces of a road split at a chunk boundary still meet exactly.
|
||||||
|
*
|
||||||
|
* Corners sharper than `maxTurnDegrees` are left alone on purpose. OpenStreetMap puts a vertex wherever a way
|
||||||
|
* genuinely turns, and a gridded town is full of real right angles; rounding those would be wrong, not pretty.
|
||||||
|
*/
|
||||||
|
export function smoothPolyline(points: FlatPoints, iterations = 2, maxTurnDegrees = 50): FlatPoints {
|
||||||
|
if (points.length < 6) return points;
|
||||||
|
|
||||||
|
// A turn of `maxTurnDegrees` away from straight has this cosine between its two directions; anything that
|
||||||
|
// turns harder falls below it and is kept as a corner.
|
||||||
|
const minCosine = Math.cos((maxTurnDegrees * Math.PI) / 180);
|
||||||
|
let current = points;
|
||||||
|
|
||||||
|
for (let pass = 0; pass < iterations; pass++) {
|
||||||
|
current = cutCorners(current, minCosine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cutCorners(points: FlatPoints, minCosine: number): FlatPoints {
|
||||||
|
const count = points.length / 2;
|
||||||
|
if (count < 3) return points;
|
||||||
|
|
||||||
|
const out: number[] = [points[0]!, points[1]!];
|
||||||
|
|
||||||
|
for (let i = 1; i < count - 1; i++) {
|
||||||
|
const px = points[(i - 1) * 2]!;
|
||||||
|
const py = points[(i - 1) * 2 + 1]!;
|
||||||
|
const cx = points[i * 2]!;
|
||||||
|
const cy = points[i * 2 + 1]!;
|
||||||
|
const nx = points[(i + 1) * 2]!;
|
||||||
|
const ny = points[(i + 1) * 2 + 1]!;
|
||||||
|
|
||||||
|
if (isSharp(px, py, cx, cy, nx, ny, minCosine)) {
|
||||||
|
out.push(cx, cy);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(px + (cx - px) * 0.75, py + (cy - py) * 0.75);
|
||||||
|
out.push(cx + (nx - cx) * 0.25, cy + (ny - cy) * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(points[(count - 1) * 2]!, points[(count - 1) * 2 + 1]!);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the direction change at the middle point exceeds the allowance. */
|
||||||
|
function isSharp(
|
||||||
|
px: number, py: number,
|
||||||
|
cx: number, cy: number,
|
||||||
|
nx: number, ny: number,
|
||||||
|
minCosine: number,
|
||||||
|
): boolean {
|
||||||
|
const ax = cx - px;
|
||||||
|
const ay = cy - py;
|
||||||
|
const bx = nx - cx;
|
||||||
|
const by = ny - cy;
|
||||||
|
|
||||||
|
const aLength = Math.hypot(ax, ay);
|
||||||
|
const bLength = Math.hypot(bx, by);
|
||||||
|
if (aLength === 0 || bLength === 0) return true;
|
||||||
|
|
||||||
|
// cos of the angle between the incoming and outgoing directions: 1 is straight on, -1 doubles back.
|
||||||
|
const cosine = (ax * bx + ay * by) / (aLength * bLength);
|
||||||
|
return cosine < minCosine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointOnPath {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Direction of travel at that point, in radians. */
|
||||||
|
angle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Places markers at a fixed spacing along a polyline, each oriented along the direction of travel. */
|
||||||
|
export function pointsAlong(points: FlatPoints, spacing: number, offset = 0): PointOnPath[] {
|
||||||
|
const placed: PointOnPath[] = [];
|
||||||
|
if (spacing <= 0 || points.length < 4) return placed;
|
||||||
|
|
||||||
|
let nextAt = offset;
|
||||||
|
let travelled = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
|
const x0 = points[i]!;
|
||||||
|
const y0 = points[i + 1]!;
|
||||||
|
const x1 = points[i + 2]!;
|
||||||
|
const y1 = points[i + 3]!;
|
||||||
|
|
||||||
|
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||||
|
if (length === 0) continue;
|
||||||
|
|
||||||
|
const angle = Math.atan2(y1 - y0, x1 - x0);
|
||||||
|
|
||||||
|
while (nextAt <= travelled + length) {
|
||||||
|
const t = (nextAt - travelled) / length;
|
||||||
|
placed.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, angle });
|
||||||
|
nextAt += spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
travelled += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function polylineLength(points: FlatPoints): number {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
|
total += Math.hypot(points[i + 2]! - points[i]!, points[i + 3]! - points[i + 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The midpoint of a polyline by arc length, with the local direction — where a street label wants to sit. */
|
||||||
|
export function polylineAnchor(points: FlatPoints): PointOnPath | null {
|
||||||
|
const half = polylineLength(points) / 2;
|
||||||
|
if (half === 0) return null;
|
||||||
|
|
||||||
|
const [anchor] = pointsAlong(points, Number.MAX_VALUE, half);
|
||||||
|
return anchor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area-weighted centroid of a ring, which sits inside the shape even when it is long or bent — unlike the
|
||||||
|
* average of its vertices, which drifts toward whichever edge has the most detail.
|
||||||
|
*/
|
||||||
|
export function polygonCentroid(points: FlatPoints): { x: number; y: number } | null {
|
||||||
|
const count = points.length / 2;
|
||||||
|
if (count < 3) return null;
|
||||||
|
|
||||||
|
let twiceArea = 0;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const j = (i + 1) % count;
|
||||||
|
const xi = points[i * 2]!;
|
||||||
|
const yi = points[i * 2 + 1]!;
|
||||||
|
const xj = points[j * 2]!;
|
||||||
|
const yj = points[j * 2 + 1]!;
|
||||||
|
|
||||||
|
const cross = xi * yj - xj * yi;
|
||||||
|
twiceArea += cross;
|
||||||
|
x += (xi + xj) * cross;
|
||||||
|
y += (yi + yj) * cross;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A degenerate ring has no area to weight by; fall back to the plain vertex average.
|
||||||
|
if (Math.abs(twiceArea) < 1e-6) {
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
sumX += points[i * 2]!;
|
||||||
|
sumY += points[i * 2 + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: sumX / count, y: sumY / count };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: x / (3 * twiceArea), y: y / (3 * twiceArea) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies a flat point array shifted by a fixed offset. */
|
||||||
|
export function translate(points: FlatPoints, dx: number, dy: number): FlatPoints {
|
||||||
|
const moved = new Array<number>(points.length);
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
moved[i] = points[i]! + dx;
|
||||||
|
moved[i + 1] = points[i + 1]! + dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */
|
||||||
|
export function boundingArea(points: FlatPoints): number {
|
||||||
|
if (points.length < 6) return 0;
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i]!;
|
||||||
|
const y = points[i + 1]!;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (maxX - minX) * (maxY - minY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Container, Text, TextStyle } from 'pixi.js';
|
||||||
|
import type { MapChunk } from '../api/types';
|
||||||
|
import { Camera, type Viewport } from './camera';
|
||||||
|
import type { DetailLevel } from './chunkRenderer';
|
||||||
|
import { collectLabelCandidates, placeLabels, type PlacedLabel } from './labelPlacement';
|
||||||
|
import type { Theme } from './theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place names, drawn in screen space rather than in the scaled world container so text stays the same size
|
||||||
|
* at every zoom.
|
||||||
|
*
|
||||||
|
* Deciding *which* names to show is comparatively expensive and runs on the same slow timer as chunk
|
||||||
|
* bookkeeping. Moving them to follow the map is nearly free and runs every frame — otherwise the labels lag
|
||||||
|
* a fast pan by up to a tenth of a second and snap back when the next placement lands.
|
||||||
|
*/
|
||||||
|
export class LabelLayer {
|
||||||
|
readonly container = new Container();
|
||||||
|
|
||||||
|
private readonly texts = new Map<string, Text>();
|
||||||
|
private placed: Array<{ label: PlacedLabel; text: Text }> = [];
|
||||||
|
private style: TextStyle;
|
||||||
|
|
||||||
|
constructor(theme: Theme) {
|
||||||
|
this.style = createStyle(theme);
|
||||||
|
this.container.label = 'labels';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: Theme): void {
|
||||||
|
this.style = createStyle(theme);
|
||||||
|
|
||||||
|
// Colours live in the style object, so every pooled label has to be rebuilt against the new one.
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
for (const text of this.texts.values()) text.destroy();
|
||||||
|
this.texts.clear();
|
||||||
|
this.placed = [];
|
||||||
|
this.container.removeChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chooses the labels to show. Comparatively expensive; call it on a timer, not every frame. */
|
||||||
|
update(chunks: Iterable<MapChunk>, camera: Camera, viewport: Viewport, detail: DetailLevel): void {
|
||||||
|
const labels = placeLabels(collectLabelCandidates(chunks, detail), camera, viewport);
|
||||||
|
const kept = new Set<string>();
|
||||||
|
|
||||||
|
this.placed = labels.map((label) => {
|
||||||
|
kept.add(label.key);
|
||||||
|
const text = this.textFor(label);
|
||||||
|
text.rotation = label.rotation;
|
||||||
|
return { label, text };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, text] of this.texts) {
|
||||||
|
if (kept.has(key)) continue;
|
||||||
|
text.destroy();
|
||||||
|
this.texts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reposition(camera, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the labels already chosen to wherever the camera now puts them. Cheap; call it every frame. */
|
||||||
|
reposition(camera: Camera, viewport: Viewport): void {
|
||||||
|
for (const { label, text } of this.placed) {
|
||||||
|
const screen = camera.worldToScreen(label.worldX, label.worldY, viewport);
|
||||||
|
text.position.set(screen.x, screen.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private textFor(label: PlacedLabel): Text {
|
||||||
|
const existing = this.texts.get(label.key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const text = new Text({ text: label.text, style: this.style });
|
||||||
|
text.anchor.set(0.5);
|
||||||
|
text.resolution = window.devicePixelRatio || 1;
|
||||||
|
text.scale.set(label.size / this.style.fontSize);
|
||||||
|
|
||||||
|
this.texts.set(label.key, text);
|
||||||
|
this.container.addChild(text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyle(theme: Theme): TextStyle {
|
||||||
|
return new TextStyle({
|
||||||
|
fontFamily: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif',
|
||||||
|
// One size is rasterised and scaled per label, so a pool of names costs a handful of textures, not one each.
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '600',
|
||||||
|
fill: theme.label,
|
||||||
|
// The halo is what lets a street name stay readable while crossing the road it names.
|
||||||
|
stroke: { color: theme.labelHalo, width: 5, join: 'round' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AreaFeature, MapChunk, RoadFeature, WaterFeature } from '../api/types';
|
||||||
|
import { Camera, type Viewport } from './camera';
|
||||||
|
import { collectLabelCandidates, placeLabels, type LabelCandidate } from './labelPlacement';
|
||||||
|
|
||||||
|
const viewport: Viewport = { width: 1000, height: 800 };
|
||||||
|
|
||||||
|
function camera(zoom = 1, x = 0, y = 0): Camera {
|
||||||
|
const c = new Camera();
|
||||||
|
c.zoom = zoom;
|
||||||
|
c.x = x;
|
||||||
|
c.y = y;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(parts: Partial<MapChunk> = {}): MapChunk {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
bounds: [-1000, -1000, 1000, 1000],
|
||||||
|
buildings: [],
|
||||||
|
roads: [],
|
||||||
|
areas: [],
|
||||||
|
water: [],
|
||||||
|
...parts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const road = (parts: Partial<RoadFeature> = {}): RoadFeature => ({
|
||||||
|
id: 1,
|
||||||
|
class: 'residential',
|
||||||
|
width: 6,
|
||||||
|
path: [-200, 0, 200, 0],
|
||||||
|
name: 'Main Street',
|
||||||
|
...parts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const area = (parts: Partial<AreaFeature> = {}): AreaFeature => ({
|
||||||
|
id: 1,
|
||||||
|
kind: 'park',
|
||||||
|
name: 'Lake View Park',
|
||||||
|
outline: [-200, -200, 200, -200, 200, 200, -200, 200],
|
||||||
|
...parts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const water = (parts: Partial<WaterFeature> = {}): WaterFeature => ({
|
||||||
|
id: 1,
|
||||||
|
kind: 'lake',
|
||||||
|
name: 'Spence Reservoir',
|
||||||
|
outline: [-300, -300, 300, -300, 300, 300, -300, 300],
|
||||||
|
...parts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidate = (parts: Partial<LabelCandidate> = {}): LabelCandidate => ({
|
||||||
|
key: 'k',
|
||||||
|
text: 'Name',
|
||||||
|
worldX: 0,
|
||||||
|
worldY: 0,
|
||||||
|
angle: 0,
|
||||||
|
priority: 50,
|
||||||
|
size: 12,
|
||||||
|
maxWidth: 10_000,
|
||||||
|
...parts,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collectLabelCandidates', () => {
|
||||||
|
it('folds a road split across chunks back into one label', () => {
|
||||||
|
// Splitting at chunk boundaries means the same street arrives as several features sharing an OSM id.
|
||||||
|
const chunks = [
|
||||||
|
chunk({ roads: [road({ id: 7, path: [-400, 0, -100, 0] })] }),
|
||||||
|
chunk({ roads: [road({ id: 7, path: [-100, 0, 500, 0] })] }),
|
||||||
|
chunk({ roads: [road({ id: 7, path: [500, 0, 560, 0] })] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const found = collectLabelCandidates(chunks, 2).filter((c) => c.key === 'r7');
|
||||||
|
|
||||||
|
expect(found).toHaveLength(1);
|
||||||
|
// The longest piece carries the name, so the label sits on the part of the street you can actually see.
|
||||||
|
expect(found[0]!.worldX).toBeCloseTo(200, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips features with no name', () => {
|
||||||
|
const chunks = [chunk({
|
||||||
|
roads: [road({ name: undefined })],
|
||||||
|
areas: [area({ name: undefined })],
|
||||||
|
water: [water({ name: undefined })],
|
||||||
|
})];
|
||||||
|
|
||||||
|
expect(collectLabelCandidates(chunks, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores watercourses, which have no outline to sit in', () => {
|
||||||
|
const chunks = [chunk({ water: [{ id: 3, kind: 'stream', name: 'Dry Creek', path: [0, 0, 100, 0] }] })];
|
||||||
|
|
||||||
|
expect(collectLabelCandidates(chunks, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops minor streets as the camera pulls back', () => {
|
||||||
|
const chunks = [chunk({
|
||||||
|
roads: [
|
||||||
|
road({ id: 1, class: 'motorway', name: 'US 208' }),
|
||||||
|
road({ id: 2, class: 'residential', name: 'Elm Street' }),
|
||||||
|
road({ id: 3, class: 'footway', name: 'River Walk' }),
|
||||||
|
],
|
||||||
|
})];
|
||||||
|
|
||||||
|
const keys = (detail: 0 | 1 | 2) => collectLabelCandidates(chunks, detail).map((c) => c.key).sort();
|
||||||
|
|
||||||
|
expect(keys(2)).toEqual(['r1', 'r2', 'r3']);
|
||||||
|
expect(keys(1)).toEqual(['r1', 'r2']);
|
||||||
|
expect(keys(0)).toEqual(['r1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops small areas as the camera pulls back', () => {
|
||||||
|
// A 400x400 m park spans 400 m, which clears the town threshold but not the far one.
|
||||||
|
const chunks = [chunk({ areas: [area()] })];
|
||||||
|
|
||||||
|
expect(collectLabelCandidates(chunks, 2)).toHaveLength(1);
|
||||||
|
expect(collectLabelCandidates(chunks, 1)).toHaveLength(1);
|
||||||
|
expect(collectLabelCandidates(chunks, 0)).toHaveLength(1);
|
||||||
|
|
||||||
|
const small = [chunk({ areas: [area({ outline: [0, 0, 30, 0, 30, 30, 0, 30] })] })];
|
||||||
|
expect(collectLabelCandidates(small, 2)).toHaveLength(1);
|
||||||
|
expect(collectLabelCandidates(small, 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks landmarks first, then arterials, then land cover, then side streets', () => {
|
||||||
|
const chunks = [chunk({
|
||||||
|
water: [water()],
|
||||||
|
areas: [area()],
|
||||||
|
roads: [road({ id: 1, class: 'primary', name: 'Highway' }), road({ id: 2, class: 'service', name: 'Alley' })],
|
||||||
|
})];
|
||||||
|
|
||||||
|
const found = new Map(collectLabelCandidates(chunks, 2).map((c) => [c.key, c.priority]));
|
||||||
|
|
||||||
|
// Water bodies are what people orient by; an arterial names more of the map than the park beside it.
|
||||||
|
expect(found.get('w1')!).toBeGreaterThan(found.get('r1')!);
|
||||||
|
expect(found.get('r1')!).toBeGreaterThan(found.get('a1')!);
|
||||||
|
expect(found.get('a1')!).toBeGreaterThan(found.get('r2')!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures how much room a road label has along its own line', () => {
|
||||||
|
const found = collectLabelCandidates([chunk({ roads: [road({ path: [0, 0, 1000, 0] })] })], 2);
|
||||||
|
|
||||||
|
expect(found[0]!.maxWidth).toBeCloseTo(900, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a road label along the direction of the road', () => {
|
||||||
|
const found = collectLabelCandidates([chunk({ roads: [road({ path: [0, -300, 0, 300] })] })], 2);
|
||||||
|
|
||||||
|
expect(found[0]!.angle).toBeCloseTo(Math.PI / 2, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeLabels', () => {
|
||||||
|
it('places the highest priority first when two collide', () => {
|
||||||
|
const placed = placeLabels(
|
||||||
|
[candidate({ key: 'low', priority: 10 }), candidate({ key: 'high', priority: 90 })],
|
||||||
|
camera(),
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(placed.map((p) => p.key)).toEqual(['high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps labels that are far enough apart', () => {
|
||||||
|
const placed = placeLabels(
|
||||||
|
[candidate({ key: 'a', worldX: -300 }), candidate({ key: 'b', worldX: 300 })],
|
||||||
|
camera(),
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(placed.map((p) => p.key).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops anything off screen', () => {
|
||||||
|
const placed = placeLabels([candidate({ key: 'far', worldX: 50_000 })], camera(), viewport);
|
||||||
|
|
||||||
|
expect(placed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a name too long for the thing it names', () => {
|
||||||
|
const long = candidate({ text: 'A Very Long Street Name Indeed', maxWidth: 20 });
|
||||||
|
|
||||||
|
expect(placeLabels([long], camera(1), viewport)).toEqual([]);
|
||||||
|
// Zooming in gives the same road more pixels, so the same name now fits.
|
||||||
|
expect(placeLabels([long], camera(20), viewport)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns more than the ceiling', () => {
|
||||||
|
const many = Array.from({ length: 400 }, (_, i) =>
|
||||||
|
candidate({ key: `k${i}`, worldX: (i % 20) * 5, worldY: Math.floor(i / 20) * 5, maxWidth: 1e9 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(placeLabels(many, camera(), viewport, 5).length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns text the right way up whichever way the road runs', () => {
|
||||||
|
// A road heading south-west would read upside down at its raw angle.
|
||||||
|
const placed = placeLabels([candidate({ angle: (Math.PI * 5) / 6 })], camera(), viewport);
|
||||||
|
|
||||||
|
expect(Math.abs(placed[0]!.rotation)).toBeLessThanOrEqual(Math.PI / 2 + 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors the world angle, because screen Y points the other way', () => {
|
||||||
|
const placed = placeLabels([candidate({ angle: Math.PI / 6 })], camera(), viewport);
|
||||||
|
|
||||||
|
expect(placed[0]!.rotation).toBeCloseTo(-Math.PI / 6, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the world position through, so the layer can follow the camera each frame', () => {
|
||||||
|
const placed = placeLabels([candidate({ worldX: 120, worldY: -45 })], camera(), viewport);
|
||||||
|
|
||||||
|
expect(placed[0]!.worldX).toBe(120);
|
||||||
|
expect(placed[0]!.worldY).toBe(-45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for equal priorities, so labels do not swap between passes', () => {
|
||||||
|
const pair = [candidate({ key: 'b', worldX: -300 }), candidate({ key: 'a', worldX: 300 })];
|
||||||
|
|
||||||
|
const first = placeLabels(pair, camera(), viewport).map((p) => p.key);
|
||||||
|
const second = placeLabels([...pair].reverse(), camera(), viewport).map((p) => p.key);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { MapChunk, RoadFeature } from '../api/types';
|
||||||
|
import { Camera, type Viewport } from './camera';
|
||||||
|
import type { DetailLevel } from './chunkRenderer';
|
||||||
|
import { polygonCentroid, polylineAnchor, polylineLength } from './geometry';
|
||||||
|
import { ROAD_RANK } from './style';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deciding which place names to show and where. Kept free of PixiJS so the ranking and the collision rules
|
||||||
|
* can be tested on their own; `labelLayer.ts` is the thin part that turns the result into text objects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LabelCandidate {
|
||||||
|
/** Stable across frames, so a label that survives a pan keeps its text object instead of being rebuilt. */
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
/** World-space direction the label follows; zero for anything placed horizontally. */
|
||||||
|
angle: number;
|
||||||
|
priority: number;
|
||||||
|
size: number;
|
||||||
|
/** Longest the label may be on screen, in world metres; drops names that will not fit their road. */
|
||||||
|
maxWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlacedLabel {
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
/** Screen-space rotation, already turned the right way up. */
|
||||||
|
rotation: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_LABELS = 140;
|
||||||
|
|
||||||
|
/** Rough width of a character relative to the font size, used for the collision box before layout. */
|
||||||
|
const CHARACTER_WIDTH_RATIO = 0.52;
|
||||||
|
|
||||||
|
/** Breathing room around each label so neighbours do not touch. */
|
||||||
|
const LABEL_PADDING = 4;
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLabelCandidates(chunks: Iterable<MapChunk>, detail: DetailLevel): LabelCandidate[] {
|
||||||
|
const candidates: LabelCandidate[] = [];
|
||||||
|
|
||||||
|
// A road split across chunks keeps its OSM id, so the pieces are folded back together and the longest one
|
||||||
|
// carries the name. Without this a street picks up a label per chunk it crosses.
|
||||||
|
const roadsById = new Map<number, RoadFeature>();
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
for (const road of chunk.roads) {
|
||||||
|
if (!road.name) continue;
|
||||||
|
if (ROAD_RANK[road.class] < minimumRoadRank(detail)) continue;
|
||||||
|
|
||||||
|
const existing = roadsById.get(road.id);
|
||||||
|
if (!existing || polylineLength(road.path) > polylineLength(existing.path)) {
|
||||||
|
roadsById.set(road.id, road);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const area of chunk.areas) {
|
||||||
|
if (!area.name) continue;
|
||||||
|
|
||||||
|
const centroid = polygonCentroid(area.outline);
|
||||||
|
if (!centroid) continue;
|
||||||
|
|
||||||
|
const size = Math.sqrt(boundingArea(area.outline));
|
||||||
|
if (size < minimumAreaSpan(detail)) continue;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
key: `a${area.id}`,
|
||||||
|
text: area.name,
|
||||||
|
worldX: centroid.x,
|
||||||
|
worldY: centroid.y,
|
||||||
|
angle: 0,
|
||||||
|
priority: 70 + Math.min(size / 100, 15),
|
||||||
|
size: 12,
|
||||||
|
maxWidth: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const water of chunk.water) {
|
||||||
|
if (!water.name || !water.outline) continue;
|
||||||
|
|
||||||
|
const centroid = polygonCentroid(water.outline);
|
||||||
|
if (!centroid) continue;
|
||||||
|
|
||||||
|
const size = Math.sqrt(boundingArea(water.outline));
|
||||||
|
if (size < minimumAreaSpan(detail)) continue;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
key: `w${water.id}`,
|
||||||
|
text: water.name,
|
||||||
|
worldX: centroid.x,
|
||||||
|
worldY: centroid.y,
|
||||||
|
angle: 0,
|
||||||
|
// Water bodies are the landmarks people orient by, so they outrank the land around them.
|
||||||
|
priority: 95 + Math.min(size / 100, 15),
|
||||||
|
size: 13,
|
||||||
|
maxWidth: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const road of roadsById.values()) {
|
||||||
|
const anchor = polylineAnchor(road.path);
|
||||||
|
if (!anchor) continue;
|
||||||
|
|
||||||
|
const rank = ROAD_RANK[road.class];
|
||||||
|
candidates.push({
|
||||||
|
key: `r${road.id}`,
|
||||||
|
text: road.name!,
|
||||||
|
worldX: anchor.x,
|
||||||
|
worldY: anchor.y,
|
||||||
|
angle: anchor.angle,
|
||||||
|
priority: 40 + rank * 5,
|
||||||
|
size: rank >= 7 ? 12 : 11,
|
||||||
|
// A name that would run off the end of its own road is worse than no name at all.
|
||||||
|
maxWidth: polylineLength(road.path) * 0.9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places what fits, most important first. The first label to claim a piece of screen keeps it; anything that
|
||||||
|
* would overlap is dropped rather than nudged, which keeps the result stable as the camera moves.
|
||||||
|
*/
|
||||||
|
export function placeLabels(
|
||||||
|
candidates: LabelCandidate[],
|
||||||
|
camera: Camera,
|
||||||
|
viewport: Viewport,
|
||||||
|
maxLabels = MAX_LABELS,
|
||||||
|
): PlacedLabel[] {
|
||||||
|
const ordered = [...candidates].sort((a, b) => b.priority - a.priority || a.key.localeCompare(b.key));
|
||||||
|
|
||||||
|
const placed: PlacedLabel[] = [];
|
||||||
|
const boxes: Rect[] = [];
|
||||||
|
|
||||||
|
for (const candidate of ordered) {
|
||||||
|
if (placed.length >= maxLabels) break;
|
||||||
|
|
||||||
|
const screen = camera.worldToScreen(candidate.worldX, candidate.worldY, viewport);
|
||||||
|
if (screen.x < 0 || screen.y < 0 || screen.x > viewport.width || screen.y > viewport.height) continue;
|
||||||
|
|
||||||
|
const width = candidate.text.length * candidate.size * CHARACTER_WIDTH_RATIO;
|
||||||
|
if (width > candidate.maxWidth * camera.zoom) continue;
|
||||||
|
|
||||||
|
// World Y grows north and screen Y grows down, so the on-screen angle is the world angle mirrored.
|
||||||
|
const rotation = uprightRotation(-candidate.angle);
|
||||||
|
const box = boundsOf(screen.x, screen.y, width, candidate.size, rotation);
|
||||||
|
if (boxes.some((other) => overlaps(box, other))) continue;
|
||||||
|
|
||||||
|
boxes.push(box);
|
||||||
|
placed.push({
|
||||||
|
key: candidate.key,
|
||||||
|
text: candidate.text,
|
||||||
|
worldX: candidate.worldX,
|
||||||
|
worldY: candidate.worldY,
|
||||||
|
rotation,
|
||||||
|
size: candidate.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which roads are worth naming at each zoom: everything at street level, only the arterials from afar. */
|
||||||
|
function minimumRoadRank(detail: DetailLevel): number {
|
||||||
|
return detail === 2 ? 0 : detail === 1 ? 5 : 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimumAreaSpan(detail: DetailLevel): number {
|
||||||
|
return detail === 2 ? 25 : detail === 1 ? 90 : 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundingArea(points: number[]): number {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i]!;
|
||||||
|
const y = points[i + 1]!;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, (maxX - minX) * (maxY - minY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps text the right way up: past a quarter turn, reading direction flips. */
|
||||||
|
function uprightRotation(rotation: number): number {
|
||||||
|
let upright = rotation;
|
||||||
|
while (upright > Math.PI / 2) upright -= Math.PI;
|
||||||
|
while (upright < -Math.PI / 2) upright += Math.PI;
|
||||||
|
return upright;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsOf(x: number, y: number, width: number, height: number, rotation: number): Rect {
|
||||||
|
const cos = Math.abs(Math.cos(rotation));
|
||||||
|
const sin = Math.abs(Math.sin(rotation));
|
||||||
|
|
||||||
|
const halfWidth = (width * cos + height * sin) / 2 + LABEL_PADDING;
|
||||||
|
const halfHeight = (width * sin + height * cos) / 2 + LABEL_PADDING;
|
||||||
|
|
||||||
|
return { left: x - halfWidth, top: y - halfHeight, right: x + halfWidth, bottom: y + halfHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: Rect, b: Rect): boolean {
|
||||||
|
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AreaKind, RoadClass, RoadFeature } from '../api/types';
|
||||||
|
import { RoadFlags } from '../api/types';
|
||||||
|
import { areaLayer, LAYER_ORDER, ROAD_BANDS, roadBand, roadLayers } from './layers';
|
||||||
|
import { ROAD_MIN_DETAIL, ROAD_RANK } from './style';
|
||||||
|
|
||||||
|
const road = (roadClass: RoadClass, flags = 0): RoadFeature => ({
|
||||||
|
id: 1,
|
||||||
|
class: roadClass,
|
||||||
|
width: 6,
|
||||||
|
path: [0, 0, 10, 0],
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = (layer: string) => LAYER_ORDER.indexOf(layer as (typeof LAYER_ORDER)[number]);
|
||||||
|
|
||||||
|
describe('LAYER_ORDER', () => {
|
||||||
|
it('has no duplicates', () => {
|
||||||
|
expect(new Set(LAYER_ORDER).size).toBe(LAYER_ORDER.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts every casing under its own fill', () => {
|
||||||
|
// This is what makes junctions merge into one surface instead of showing each other's outlines.
|
||||||
|
for (const band of ROAD_BANDS) {
|
||||||
|
const { casing, fill } = roadLayers(band);
|
||||||
|
expect(order(casing)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(order(casing)).toBeLessThan(order(fill));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks the map the way a map stacks: ground, water, roads, buildings', () => {
|
||||||
|
expect(order('areaZones')).toBeLessThan(order('areaNatural'));
|
||||||
|
expect(order('areaNatural')).toBeLessThan(order('areaDetail'));
|
||||||
|
expect(order('areaDetail')).toBeLessThan(order('water'));
|
||||||
|
expect(order('water')).toBeLessThan(order('minorCasing'));
|
||||||
|
expect(order('bridgeFill')).toBeLessThan(order('buildingWalls'));
|
||||||
|
expect(order('buildingWalls')).toBeLessThan(order('buildings'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs road bands from least to most important, with tunnels below and bridges above', () => {
|
||||||
|
expect(order('tunnelFill')).toBeLessThan(order('minorCasing'));
|
||||||
|
expect(order('minorFill')).toBeLessThan(order('localCasing'));
|
||||||
|
expect(order('localFill')).toBeLessThan(order('secondaryCasing'));
|
||||||
|
expect(order('secondaryFill')).toBeLessThan(order('majorCasing'));
|
||||||
|
expect(order('majorFill')).toBeLessThan(order('bridgeCasing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws one-way arrows over every road band', () => {
|
||||||
|
expect(order('bridgeFill')).toBeLessThan(order('roadArrows'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roadBand', () => {
|
||||||
|
it('sorts classes into bands by importance', () => {
|
||||||
|
expect(roadBand(road('motorway'))).toBe('major');
|
||||||
|
expect(roadBand(road('trunk'))).toBe('major');
|
||||||
|
expect(roadBand(road('primary'))).toBe('major');
|
||||||
|
expect(roadBand(road('secondary'))).toBe('secondary');
|
||||||
|
expect(roadBand(road('tertiary'))).toBe('secondary');
|
||||||
|
expect(roadBand(road('residential'))).toBe('local');
|
||||||
|
expect(roadBand(road('livingStreet'))).toBe('local');
|
||||||
|
expect(roadBand(road('service'))).toBe('minor');
|
||||||
|
expect(roadBand(road('footway'))).toBe('minor');
|
||||||
|
expect(roadBand(road('railway'))).toBe('minor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets bridges and tunnels override the class', () => {
|
||||||
|
// A motorway bridge belongs above whatever it crosses, not in the band its class would earn.
|
||||||
|
expect(roadBand(road('motorway', RoadFlags.bridge))).toBe('bridge');
|
||||||
|
expect(roadBand(road('footway', RoadFlags.bridge))).toBe('bridge');
|
||||||
|
expect(roadBand(road('motorway', RoadFlags.tunnel))).toBe('tunnel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers bridge over tunnel when a way is somehow tagged both', () => {
|
||||||
|
expect(roadBand(road('primary', RoadFlags.bridge | RoadFlags.tunnel))).toBe('bridge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores flags that say nothing about height', () => {
|
||||||
|
expect(roadBand(road('residential', RoadFlags.oneway | RoadFlags.unpaved))).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a road with no flags at all', () => {
|
||||||
|
const { flags: _flags, ...bare } = road('residential');
|
||||||
|
expect(roadBand(bare as RoadFeature)).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names layers that actually exist', () => {
|
||||||
|
for (const band of ROAD_BANDS) {
|
||||||
|
const { casing, fill } = roadLayers(band);
|
||||||
|
expect(LAYER_ORDER).toContain(casing);
|
||||||
|
expect(LAYER_ORDER).toContain(fill);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('areaLayer', () => {
|
||||||
|
it('puts deliberate spaces above natural cover, and both above zoning', () => {
|
||||||
|
expect(areaLayer('park')).toBe('areaDetail');
|
||||||
|
expect(areaLayer('pitch')).toBe('areaDetail');
|
||||||
|
expect(areaLayer('forest')).toBe('areaNatural');
|
||||||
|
expect(areaLayer('farmland')).toBe('areaNatural');
|
||||||
|
expect(areaLayer('residentialZone')).toBe('areaZones');
|
||||||
|
expect(areaLayer('parking')).toBe('areaZones');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends anything unrecognised to the bottom band', () => {
|
||||||
|
expect(areaLayer('unknown')).toBe('areaZones');
|
||||||
|
expect(areaLayer('nonsense' as AreaKind)).toBe('areaZones');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('road tables', () => {
|
||||||
|
it('rank and detail cover the same set of classes', () => {
|
||||||
|
expect(Object.keys(ROAD_RANK).sort()).toEqual(Object.keys(ROAD_MIN_DETAIL).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hides a road that outranks one still drawn', () => {
|
||||||
|
// A footpath must not survive to a zoom where a trunk road has already been dropped.
|
||||||
|
const classes = Object.keys(ROAD_RANK) as RoadClass[];
|
||||||
|
|
||||||
|
for (const a of classes) {
|
||||||
|
for (const b of classes) {
|
||||||
|
if (ROAD_RANK[a] > ROAD_RANK[b]) {
|
||||||
|
expect(ROAD_MIN_DETAIL[a]).toBeLessThanOrEqual(ROAD_MIN_DETAIL[b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Container } from 'pixi.js';
|
||||||
|
import type { AreaKind, RoadFeature } from '../api/types';
|
||||||
|
import { RoadFlags } from '../api/types';
|
||||||
|
import { ROAD_RANK } from './style';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw order for the whole map, back to front.
|
||||||
|
*
|
||||||
|
* Every chunk contributes its geometry to these shared layers rather than to a container of its own. That
|
||||||
|
* matters: with per-chunk containers the ordering would only hold inside a chunk, so a side street loaded
|
||||||
|
* after a trunk road would paint straight over it wherever the two meet.
|
||||||
|
*
|
||||||
|
* Within each band the casing goes down first and the fill on top, which is what makes junctions read as
|
||||||
|
* junctions: the fills merge into one continuous surface instead of showing each other's outlines.
|
||||||
|
*/
|
||||||
|
export const LAYER_ORDER = [
|
||||||
|
'areaZones',
|
||||||
|
'areaNatural',
|
||||||
|
'areaDetail',
|
||||||
|
'water',
|
||||||
|
'tunnelCasing',
|
||||||
|
'tunnelFill',
|
||||||
|
'minorCasing',
|
||||||
|
'minorFill',
|
||||||
|
'localCasing',
|
||||||
|
'localFill',
|
||||||
|
'secondaryCasing',
|
||||||
|
'secondaryFill',
|
||||||
|
'majorCasing',
|
||||||
|
'majorFill',
|
||||||
|
'bridgeCasing',
|
||||||
|
'bridgeFill',
|
||||||
|
'roadArrows',
|
||||||
|
'buildingWalls',
|
||||||
|
'buildings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LayerId = (typeof LAYER_ORDER)[number];
|
||||||
|
|
||||||
|
export type MapLayers = Record<LayerId, Container>;
|
||||||
|
|
||||||
|
export type RoadBand = 'tunnel' | 'minor' | 'local' | 'secondary' | 'major' | 'bridge';
|
||||||
|
|
||||||
|
export const ROAD_BANDS: readonly RoadBand[] = ['tunnel', 'minor', 'local', 'secondary', 'major', 'bridge'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges rise above everything they cross and tunnels sink below it; otherwise a road sits in the band its
|
||||||
|
* class earns.
|
||||||
|
*/
|
||||||
|
export function roadBand(road: RoadFeature): RoadBand {
|
||||||
|
const flags = road.flags ?? 0;
|
||||||
|
if (flags & RoadFlags.bridge) return 'bridge';
|
||||||
|
if (flags & RoadFlags.tunnel) return 'tunnel';
|
||||||
|
|
||||||
|
const rank = ROAD_RANK[road.class];
|
||||||
|
if (rank >= 9) return 'major';
|
||||||
|
if (rank >= 7) return 'secondary';
|
||||||
|
if (rank >= 5) return 'local';
|
||||||
|
return 'minor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roadLayers(band: RoadBand): { casing: LayerId; fill: LayerId } {
|
||||||
|
return { casing: `${band}Casing`, fill: `${band}Fill` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Land cover in three passes: zoning blocks underneath, natural cover over them, and the small deliberate
|
||||||
|
* spaces — parks, gardens, pitches — on top.
|
||||||
|
*/
|
||||||
|
export function areaLayer(kind: AreaKind): LayerId {
|
||||||
|
switch (kind) {
|
||||||
|
case 'park':
|
||||||
|
case 'garden':
|
||||||
|
case 'pitch':
|
||||||
|
case 'cemetery':
|
||||||
|
case 'beach':
|
||||||
|
return 'areaDetail';
|
||||||
|
|
||||||
|
case 'forest':
|
||||||
|
case 'grass':
|
||||||
|
case 'meadow':
|
||||||
|
case 'orchard':
|
||||||
|
case 'scrub':
|
||||||
|
case 'heath':
|
||||||
|
case 'sand':
|
||||||
|
case 'bareRock':
|
||||||
|
case 'wetland':
|
||||||
|
case 'farmland':
|
||||||
|
return 'areaNatural';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'areaZones';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
import { Application, Container, Graphics } from 'pixi.js';
|
import { 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every colour the map draws lives here. Nothing else in the renderer names a colour, so a new look is a new
|
||||||
|
* entry in {@link THEMES} rather than a sweep through the drawing code.
|
||||||
|
*/
|
||||||
|
export type ThemeName = 'day' | 'night';
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
name: ThemeName;
|
||||||
|
/** True when the surrounding page chrome should switch to its dark variant too. */
|
||||||
|
dark: boolean;
|
||||||
|
|
||||||
|
/** Beyond the generated square. */
|
||||||
|
outside: number;
|
||||||
|
/** The square itself, under all land cover. */
|
||||||
|
land: number;
|
||||||
|
border: number;
|
||||||
|
|
||||||
|
areas: Record<AreaKind, number>;
|
||||||
|
water: number;
|
||||||
|
waterEdge: number;
|
||||||
|
waterLines: Record<WaterKind, number>;
|
||||||
|
|
||||||
|
roadFill: Record<RoadClass, number>;
|
||||||
|
roadCasing: Record<RoadClass, number>;
|
||||||
|
railBed: number;
|
||||||
|
railSleeper: number;
|
||||||
|
/** How much of an underground stretch shows through the surface. */
|
||||||
|
tunnelAlpha: number;
|
||||||
|
|
||||||
|
buildingBase: number;
|
||||||
|
buildingTall: number;
|
||||||
|
buildingEdge: number;
|
||||||
|
buildingTints: Partial<Record<BuildingKind, number>>;
|
||||||
|
/** Walls are the roof colour pulled this far toward {@link buildingWall}. */
|
||||||
|
buildingWall: number;
|
||||||
|
buildingWallMix: number;
|
||||||
|
|
||||||
|
arrow: number;
|
||||||
|
arrowAlpha: number;
|
||||||
|
|
||||||
|
label: number;
|
||||||
|
labelHalo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY: Theme = {
|
||||||
|
name: 'day',
|
||||||
|
dark: false,
|
||||||
|
|
||||||
|
outside: 0xd5d9d2,
|
||||||
|
land: 0xeceee7,
|
||||||
|
border: 0xb4bab2,
|
||||||
|
|
||||||
|
areas: {
|
||||||
|
unknown: 0xe6e6e0,
|
||||||
|
forest: 0xc6ddb8,
|
||||||
|
grass: 0xd8e9c6,
|
||||||
|
meadow: 0xdfeecd,
|
||||||
|
farmland: 0xece7c8,
|
||||||
|
orchard: 0xd9e7bd,
|
||||||
|
scrub: 0xd9e6c9,
|
||||||
|
heath: 0xdfe4cb,
|
||||||
|
sand: 0xf0e9cf,
|
||||||
|
bareRock: 0xdedcd5,
|
||||||
|
wetland: 0xd2e2d7,
|
||||||
|
park: 0xd2eac0,
|
||||||
|
garden: 0xd9edc6,
|
||||||
|
pitch: 0xc9e6b4,
|
||||||
|
cemetery: 0xd6e0cd,
|
||||||
|
residentialZone: 0xe6e3de,
|
||||||
|
industrialZone: 0xe3dee2,
|
||||||
|
commercialZone: 0xeae0dd,
|
||||||
|
retailZone: 0xeddfd7,
|
||||||
|
quarry: 0xdfdad2,
|
||||||
|
parking: 0xe7e4dd,
|
||||||
|
school: 0xeae3d7,
|
||||||
|
beach: 0xf4ead1,
|
||||||
|
},
|
||||||
|
|
||||||
|
water: 0x9fc9e0,
|
||||||
|
waterEdge: 0x84b4cf,
|
||||||
|
waterLines: {
|
||||||
|
unknown: 0x9fc9e0,
|
||||||
|
water: 0x9fc9e0,
|
||||||
|
lake: 0x9fc9e0,
|
||||||
|
pond: 0x9fc9e0,
|
||||||
|
reservoir: 0x9fc9e0,
|
||||||
|
riverbank: 0x9fc9e0,
|
||||||
|
river: 0x9fc9e0,
|
||||||
|
stream: 0xa9d0e4,
|
||||||
|
canal: 0x9fc9e0,
|
||||||
|
ditch: 0xb5d7e8,
|
||||||
|
drain: 0xb5d7e8,
|
||||||
|
},
|
||||||
|
|
||||||
|
roadFill: {
|
||||||
|
unknown: 0xf7f6f4,
|
||||||
|
motorway: 0xf3ae68,
|
||||||
|
trunk: 0xf7c489,
|
||||||
|
primary: 0xfad89b,
|
||||||
|
secondary: 0xfae9b8,
|
||||||
|
tertiary: 0xfdfaf0,
|
||||||
|
unclassified: 0xffffff,
|
||||||
|
residential: 0xffffff,
|
||||||
|
livingStreet: 0xf7f7f5,
|
||||||
|
service: 0xfbfbfa,
|
||||||
|
track: 0xe6d9bd,
|
||||||
|
pedestrian: 0xf1eee9,
|
||||||
|
footway: 0xd89a76,
|
||||||
|
cycleway: 0x8fa4cf,
|
||||||
|
steps: 0xd08a68,
|
||||||
|
path: 0xcfa484,
|
||||||
|
railway: 0xb2aca6,
|
||||||
|
},
|
||||||
|
|
||||||
|
roadCasing: {
|
||||||
|
unknown: 0xd8d4cd,
|
||||||
|
motorway: 0xd48f45,
|
||||||
|
trunk: 0xd9a163,
|
||||||
|
primary: 0xdcb872,
|
||||||
|
secondary: 0xdfcb8c,
|
||||||
|
tertiary: 0xd2cec6,
|
||||||
|
unclassified: 0xd2cec6,
|
||||||
|
residential: 0xd2cec6,
|
||||||
|
livingStreet: 0xd2cec6,
|
||||||
|
service: 0xdad6cf,
|
||||||
|
track: 0xc7b795,
|
||||||
|
pedestrian: 0xd6d1c9,
|
||||||
|
footway: 0xf2ece4,
|
||||||
|
cycleway: 0xf2ece4,
|
||||||
|
steps: 0xf2ece4,
|
||||||
|
path: 0xf2ece4,
|
||||||
|
railway: 0x8d8781,
|
||||||
|
},
|
||||||
|
|
||||||
|
railBed: 0x8d8781,
|
||||||
|
railSleeper: 0xf4f2ee,
|
||||||
|
tunnelAlpha: 0.45,
|
||||||
|
|
||||||
|
buildingBase: 0xdfd7cc,
|
||||||
|
buildingTall: 0xc4b8a8,
|
||||||
|
buildingEdge: 0xa89d8d,
|
||||||
|
buildingTints: {
|
||||||
|
church: 0xd9cfc4,
|
||||||
|
school: 0xdcd6c4,
|
||||||
|
civic: 0xd8d2c6,
|
||||||
|
industrial: 0xd7d1cd,
|
||||||
|
retail: 0xe2d5cc,
|
||||||
|
commercial: 0xe0d6cd,
|
||||||
|
shed: 0xe3ddd4,
|
||||||
|
garage: 0xe3ddd4,
|
||||||
|
},
|
||||||
|
buildingWall: 0x6f6455,
|
||||||
|
buildingWallMix: 0.45,
|
||||||
|
|
||||||
|
arrow: 0x6e6a63,
|
||||||
|
arrowAlpha: 0.5,
|
||||||
|
|
||||||
|
label: 0x3a3833,
|
||||||
|
labelHalo: 0xfbfaf7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NIGHT: Theme = {
|
||||||
|
name: 'night',
|
||||||
|
dark: true,
|
||||||
|
|
||||||
|
outside: 0x0b0e11,
|
||||||
|
land: 0x161b20,
|
||||||
|
border: 0x2c343c,
|
||||||
|
|
||||||
|
areas: {
|
||||||
|
unknown: 0x1a1f24,
|
||||||
|
forest: 0x18291d,
|
||||||
|
grass: 0x1d2e21,
|
||||||
|
meadow: 0x1f3124,
|
||||||
|
farmland: 0x272516,
|
||||||
|
orchard: 0x1c2c1a,
|
||||||
|
scrub: 0x1e2a1f,
|
||||||
|
heath: 0x232719,
|
||||||
|
sand: 0x2c2819,
|
||||||
|
bareRock: 0x22242a,
|
||||||
|
wetland: 0x152a26,
|
||||||
|
park: 0x1a3221,
|
||||||
|
garden: 0x1d3524,
|
||||||
|
pitch: 0x1f3d25,
|
||||||
|
cemetery: 0x1c2621,
|
||||||
|
residentialZone: 0x1b2027,
|
||||||
|
industrialZone: 0x1f1e26,
|
||||||
|
commercialZone: 0x231e24,
|
||||||
|
retailZone: 0x261f20,
|
||||||
|
quarry: 0x232019,
|
||||||
|
parking: 0x1e2026,
|
||||||
|
school: 0x24211b,
|
||||||
|
beach: 0x2e2a1c,
|
||||||
|
},
|
||||||
|
|
||||||
|
water: 0x123243,
|
||||||
|
waterEdge: 0x1c4a60,
|
||||||
|
waterLines: {
|
||||||
|
unknown: 0x123243,
|
||||||
|
water: 0x123243,
|
||||||
|
lake: 0x123243,
|
||||||
|
pond: 0x123243,
|
||||||
|
reservoir: 0x123243,
|
||||||
|
riverbank: 0x123243,
|
||||||
|
river: 0x17415a,
|
||||||
|
stream: 0x1a4a63,
|
||||||
|
canal: 0x17415a,
|
||||||
|
ditch: 0x1c4258,
|
||||||
|
drain: 0x1c4258,
|
||||||
|
},
|
||||||
|
|
||||||
|
roadFill: {
|
||||||
|
unknown: 0x3b424b,
|
||||||
|
motorway: 0xd08a3a,
|
||||||
|
trunk: 0xc07f39,
|
||||||
|
primary: 0xbb9445,
|
||||||
|
secondary: 0x9a8a4c,
|
||||||
|
tertiary: 0x555f6a,
|
||||||
|
unclassified: 0x4a535e,
|
||||||
|
residential: 0x4a535e,
|
||||||
|
livingStreet: 0x454e58,
|
||||||
|
service: 0x3b424b,
|
||||||
|
track: 0x453d2c,
|
||||||
|
pedestrian: 0x424852,
|
||||||
|
footway: 0x6a5344,
|
||||||
|
cycleway: 0x3b4761,
|
||||||
|
steps: 0x6d4f3d,
|
||||||
|
path: 0x5e4c3e,
|
||||||
|
railway: 0x4e545c,
|
||||||
|
},
|
||||||
|
|
||||||
|
roadCasing: {
|
||||||
|
unknown: 0x1e232a,
|
||||||
|
motorway: 0x7a4d17,
|
||||||
|
trunk: 0x6f471b,
|
||||||
|
primary: 0x6b5320,
|
||||||
|
secondary: 0x585027,
|
||||||
|
tertiary: 0x272d35,
|
||||||
|
unclassified: 0x252b33,
|
||||||
|
residential: 0x252b33,
|
||||||
|
livingStreet: 0x252b33,
|
||||||
|
service: 0x21262d,
|
||||||
|
track: 0x272215,
|
||||||
|
pedestrian: 0x242932,
|
||||||
|
footway: 0x1c2027,
|
||||||
|
cycleway: 0x1c2027,
|
||||||
|
steps: 0x1c2027,
|
||||||
|
path: 0x1c2027,
|
||||||
|
railway: 0x2a2f36,
|
||||||
|
},
|
||||||
|
|
||||||
|
railBed: 0x2a2f36,
|
||||||
|
railSleeper: 0x7d848d,
|
||||||
|
tunnelAlpha: 0.4,
|
||||||
|
|
||||||
|
// At night the taller a building is, the more of the sky it catches, so the ramp runs lighter.
|
||||||
|
buildingBase: 0x2b323a,
|
||||||
|
buildingTall: 0x424c59,
|
||||||
|
buildingEdge: 0x4a5563,
|
||||||
|
buildingTints: {
|
||||||
|
church: 0x353040,
|
||||||
|
school: 0x33352a,
|
||||||
|
civic: 0x2f3540,
|
||||||
|
industrial: 0x2e2f36,
|
||||||
|
retail: 0x362d30,
|
||||||
|
commercial: 0x343039,
|
||||||
|
shed: 0x252b32,
|
||||||
|
garage: 0x252b32,
|
||||||
|
},
|
||||||
|
buildingWall: 0x0d1116,
|
||||||
|
buildingWallMix: 0.5,
|
||||||
|
|
||||||
|
arrow: 0x9aa3ad,
|
||||||
|
arrowAlpha: 0.45,
|
||||||
|
|
||||||
|
label: 0xd7dde3,
|
||||||
|
labelHalo: 0x0d1116,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THEMES: Record<ThemeName, Theme> = { day: DAY, night: NIGHT };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
|
||||||
|
* Anything above roughly 30 m is already at the far end of the ramp.
|
||||||
|
*/
|
||||||
|
export function roofColor(theme: Theme, kind: BuildingKind, height: number): number {
|
||||||
|
const base = theme.buildingTints[kind] ?? theme.buildingBase;
|
||||||
|
return mix(base, theme.buildingTall, clamp01((height - 3) / 27));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walls are the roof pulled toward the theme's shadow colour, which keeps the two obviously related. */
|
||||||
|
export function wallColor(theme: Theme, roof: number): number {
|
||||||
|
return mix(roof, theme.buildingWall, theme.buildingWallMix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(from: number, to: number, t: number): number {
|
||||||
|
const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t);
|
||||||
|
const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t);
|
||||||
|
const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t);
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
:root {
|
: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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user