Enhance README with detailed testing instructions and clarify world generation process; implement chunk boundary handling in OsmWorldBuilder; integrate Vitest for testing in the web project; add theme toggle functionality and improve styling in the web interface.

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