Update README with project details and setup instructions; add data directories to .gitignore

This commit is contained in:
Leonid Pershin
2026-08-16 17:09:16 +03:00
parent 9d09d6e97f
commit 8460921bfa
73 changed files with 6978 additions and 1 deletions
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>The Living World</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="stage"></div>
<aside id="panel" class="panel">
<header class="panel__header">
<h1>The Living World</h1>
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
</header>
<form id="generate-form" class="form">
<label class="field">
<span>Name</span>
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
</label>
<div class="field-row">
<label class="field">
<span>Latitude</span>
<input id="field-lat" type="number" step="any" value="31.8966010" required />
</label>
<label class="field">
<span>Longitude</span>
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
</label>
</div>
<label class="field">
<span>Size <output id="field-size-value">10 km</output></span>
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
</label>
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
<section class="worlds">
<h2>Worlds</h2>
<ul id="world-list" class="world-list"></ul>
</section>
<footer id="status" class="status"></footer>
</aside>
<div id="hud" class="hud"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+917
View File
@@ -0,0 +1,917 @@
{
"name": "the-living-world-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "the-living-world-web",
"version": "0.1.0",
"dependencies": {
"pixi.js": "^8.19.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"typescript": "^5.9.3",
"vite": "^8.2.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"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/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.14",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
"integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"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/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"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/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz",
"integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==",
"license": "MIT",
"workspaces": [
"examples",
"playground"
],
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.13",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.2.0",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.144.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.2.4",
"@rolldown/binding-darwin-arm64": "1.2.4",
"@rolldown/binding-darwin-x64": "1.2.4",
"@rolldown/binding-freebsd-x64": "1.2.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
"@rolldown/binding-linux-arm64-musl": "1.2.4",
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
"@rolldown/binding-linux-x64-gnu": "1.2.4",
"@rolldown/binding-linux-x64-musl": "1.2.4",
"@rolldown/binding-openharmony-arm64": "1.2.4",
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
"@rolldown/binding-win32-x64-msvc": "1.2.4"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.25",
"rolldown": "~1.2.1",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "the-living-world-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"pixi.js": "^8.19.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"typescript": "^5.9.3",
"vite": "^8.2.1"
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
const BASE = '/api/worlds';
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(await describeFailure(response));
}
return (await response.json()) as T;
}
async function describeFailure(response: Response): Promise<string> {
try {
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
if (details) return details;
if (problem.title) return problem.title;
} catch {
// Not a problem-details body; fall through to the status line.
}
return `${response.status} ${response.statusText}`;
}
export const api = {
listWorlds: () => request<WorldSummary[]>(BASE),
createWorld: (body: CreateWorldRequest) =>
request<WorldSummary>(BASE, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
getWorld: (id: string) => request<WorldSummary>(`${BASE}/${id}`),
getMap: (id: string) => request<WorldMap>(`${BASE}/${id}/map`),
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
deleteWorld: async (id: string): Promise<void> => {
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(await describeFailure(response));
},
};
/** Polls a world until generation finishes, reporting each stage change as it happens. */
export async function waitForWorld(
id: string,
onProgress: (summary: WorldSummary) => void,
intervalMs = 1500,
): Promise<WorldSummary> {
for (;;) {
const summary = await api.getWorld(id);
onProgress(summary);
if (summary.status === 'ready' || summary.status === 'failed') return summary;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
+132
View File
@@ -0,0 +1,132 @@
/** Mirrors the DTOs in TheLivingWorld.Core.Contracts. Keep both sides in step. */
export type WorldStatus = 'pending' | 'generating' | 'ready' | 'failed';
export interface WorldStats {
buildings: number;
roads: number;
areas: number;
water: number;
vertices: number;
}
export interface WorldSummary {
id: string;
name: string;
latitude: number;
longitude: number;
sizeMeters: number;
status: WorldStatus;
stage?: string;
error?: string;
createdAt: string;
stats?: WorldStats;
}
export interface CreateWorldRequest {
name?: string;
latitude: number;
longitude: number;
sizeKm: number;
forceRefresh?: boolean;
}
/** [minX, minY, maxX, maxY] in world metres. */
export type BoundsArray = [number, number, number, number];
export interface ChunkIndexEntry {
x: number;
y: number;
bounds: BoundsArray;
features: number;
}
export interface WorldMap {
id: string;
name: string;
latitude: number;
longitude: number;
sizeMeters: number;
bounds: { south: number; west: number; north: number; east: number };
chunkSizeMeters: number;
chunkCountX: number;
chunkCountY: number;
generatedAt: string;
stats: WorldStats;
chunks: ChunkIndexEntry[];
}
export type BuildingKind =
| 'unknown' | 'house' | 'residential' | 'apartments' | 'commercial' | 'retail' | 'industrial'
| 'civic' | 'school' | 'church' | 'garage' | 'shed' | 'farm' | 'ruins';
export type RoadClass =
| 'unknown' | 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'unclassified'
| 'residential' | 'livingStreet' | 'service' | 'track' | 'pedestrian' | 'footway' | 'cycleway'
| 'steps' | 'path' | 'railway';
export type AreaKind =
| 'unknown' | 'forest' | 'grass' | 'meadow' | 'farmland' | 'orchard' | 'scrub' | 'heath' | 'sand'
| 'bareRock' | 'wetland' | 'park' | 'garden' | 'pitch' | 'cemetery' | 'residentialZone'
| 'industrialZone' | 'commercialZone' | 'retailZone' | 'quarry' | 'parking' | 'school' | 'beach';
export type WaterKind =
| 'unknown' | 'water' | 'lake' | 'pond' | 'reservoir' | 'riverbank' | 'river' | 'stream'
| 'canal' | 'ditch' | 'drain';
/** Bit values matching RoadFlags on the server. */
export const RoadFlags = {
bridge: 1,
tunnel: 2,
oneway: 4,
unpaved: 8,
} as const;
/** Geometry travels as flat [x0, y0, x1, y1, ...] arrays, ready for Graphics.poly(). */
export type FlatPoints = number[];
export interface BuildingFeature {
id: number;
kind: BuildingKind;
height: number;
outline: FlatPoints;
holes?: FlatPoints[];
name?: string;
}
export interface RoadFeature {
id: number;
class: RoadClass;
width: number;
path: FlatPoints;
flags?: number;
name?: string;
}
export interface AreaFeature {
id: number;
kind: AreaKind;
outline: FlatPoints;
holes?: FlatPoints[];
name?: string;
}
export interface WaterFeature {
id: number;
kind: WaterKind;
outline?: FlatPoints;
holes?: FlatPoints[];
path?: FlatPoints;
width?: number;
name?: string;
}
export interface MapChunk {
x: number;
y: number;
bounds: BoundsArray;
buildings: BuildingFeature[];
roads: RoadFeature[];
areas: AreaFeature[];
water: WaterFeature[];
}
+204
View File
@@ -0,0 +1,204 @@
import './styles.css';
import { api, waitForWorld } from './api/client';
import type { WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView';
const LAST_WORLD_KEY = 'the-living-world:last-world';
const elements = {
stage: required<HTMLDivElement>('stage'),
form: required<HTMLFormElement>('generate-form'),
name: required<HTMLInputElement>('field-name'),
latitude: required<HTMLInputElement>('field-lat'),
longitude: required<HTMLInputElement>('field-lon'),
size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'),
generate: required<HTMLButtonElement>('generate-button'),
worldList: required<HTMLUListElement>('world-list'),
status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'),
};
const view = new MapView();
let activeWorldId: string | null = null;
function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing element #${id}`);
return element as T;
}
function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): void {
elements.status.textContent = message;
elements.status.dataset.tone = tone;
}
function renderHud(status: MapStatus): void {
const scale = status.metersPerPixel >= 10
? `${Math.round(status.metersPerPixel)} m/px`
: `${status.metersPerPixel.toFixed(1)} m/px`;
const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`;
const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`;
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
}
async function refreshWorldList(): Promise<WorldSummary[]> {
const worlds = await api.listWorlds();
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
return worlds;
}
function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li');
item.className = 'world';
item.dataset.status = world.status;
if (world.id === activeWorldId) item.dataset.active = 'true';
const open = document.createElement('button');
open.type = 'button';
open.className = 'world__open';
open.disabled = world.status !== 'ready';
const title = document.createElement('span');
title.className = 'world__name';
title.textContent = world.name;
const detail = document.createElement('span');
detail.className = 'world__detail';
detail.textContent = describeWorld(world);
open.append(title, detail);
open.addEventListener('click', () => {
void openWorld(world.id);
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'world__delete';
remove.title = 'Delete world';
remove.textContent = '×';
remove.addEventListener('click', () => {
void deleteWorld(world);
});
item.append(open, remove);
return item;
}
function describeWorld(world: WorldSummary): string {
if (world.status === 'failed') return world.error ?? 'Generation failed';
if (world.status !== 'ready') return world.stage ?? world.status;
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
if (!world.stats) return size;
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
}
async function openWorld(id: string): Promise<void> {
try {
setStatus('Loading map…', 'busy');
const map = await api.getMap(id);
activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id);
view.showWorld(map);
setStatus(`${map.name}${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`);
await refreshWorldList();
} catch (error) {
setStatus(`Could not open world: ${message(error)}`, 'error');
}
}
async function deleteWorld(world: WorldSummary): Promise<void> {
if (!confirm(`Delete "${world.name}"?`)) return;
try {
await api.deleteWorld(world.id);
if (activeWorldId === world.id) {
activeWorldId = null;
localStorage.removeItem(LAST_WORLD_KEY);
view.clear();
elements.hud.textContent = '';
}
await refreshWorldList();
setStatus(`Deleted ${world.name}`);
} catch (error) {
setStatus(`Could not delete world: ${message(error)}`, 'error');
}
}
async function generate(event: SubmitEvent): Promise<void> {
event.preventDefault();
const latitude = Number(elements.latitude.value);
const longitude = Number(elements.longitude.value);
const sizeKm = Number(elements.size.value);
elements.generate.disabled = true;
setStatus('Requesting world…', 'busy');
try {
const created = await api.createWorld({
name: elements.name.value.trim() || undefined,
latitude,
longitude,
sizeKm,
});
await refreshWorldList();
const finished = await waitForWorld(created.id, (summary) => {
setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy');
void refreshWorldList();
});
if (finished.status === 'failed') {
setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error');
return;
}
await openWorld(finished.id);
} catch (error) {
setStatus(`Generation failed: ${message(error)}`, 'error');
} finally {
elements.generate.disabled = false;
void refreshWorldList();
}
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function start(): Promise<void> {
elements.size.addEventListener('input', () => {
elements.sizeValue.textContent = `${elements.size.value} km`;
});
elements.form.addEventListener('submit', (event) => {
void generate(event);
});
view.onStatusChange = renderHud;
await view.init(elements.stage);
try {
const worlds = await refreshWorldList();
const remembered = localStorage.getItem(LAST_WORLD_KEY);
const target =
worlds.find((world) => world.id === remembered && world.status === 'ready') ??
worlds.find((world) => world.status === 'ready');
if (target) await openWorld(target.id);
else setStatus('No worlds yet — generate one to get started.');
} catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error');
}
}
void start();
+97
View File
@@ -0,0 +1,97 @@
export interface Viewport {
width: number;
height: number;
}
export interface WorldRect {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Maps world metres to screen pixels. World Y grows north, screen Y grows down, so the vertical scale is
* negative — the one place the flip is expressed, and every other module can think in map coordinates.
*/
export class Camera {
/** Camera centre, in world metres. */
x = 0;
y = 0;
/** Screen pixels per world metre. */
zoom = 0.1;
minZoom = 0.005;
maxZoom = 6;
/** Frames the whole world square with a little breathing room and pins the zoom-out limit to it. */
fit(worldSizeMeters: number, viewport: Viewport): void {
const usable = Math.min(viewport.width, viewport.height);
this.zoom = usable > 0 ? (usable / worldSizeMeters) * 0.92 : 0.1;
this.minZoom = this.zoom * 0.35;
this.maxZoom = 6;
this.x = 0;
this.y = 0;
}
translateBy(screenDx: number, screenDy: number): void {
this.x -= screenDx / this.zoom;
this.y += screenDy / this.zoom;
}
/** Zooms about a screen anchor so the world point under the cursor stays put. */
zoomAt(factor: number, screenX: number, screenY: number, viewport: Viewport): void {
const before = this.screenToWorld(screenX, screenY, viewport);
this.zoom = clamp(this.zoom * factor, this.minZoom, this.maxZoom);
const after = this.screenToWorld(screenX, screenY, viewport);
this.x += before.x - after.x;
this.y += before.y - after.y;
}
/** Keeps the centre inside the world square so the map cannot be flung off screen. */
clampToWorld(worldSizeMeters: number): void {
const half = worldSizeMeters / 2;
this.x = clamp(this.x, -half, half);
this.y = clamp(this.y, -half, half);
}
screenToWorld(screenX: number, screenY: number, viewport: Viewport): { x: number; y: number } {
return {
x: this.x + (screenX - viewport.width / 2) / this.zoom,
y: this.y - (screenY - viewport.height / 2) / 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;
const halfHeight = viewport.height / 2 / this.zoom;
const padX = halfWidth * padding;
const padY = halfHeight * padding;
return {
minX: this.x - halfWidth - padX,
minY: this.y - halfHeight - padY,
maxX: this.x + halfWidth + padX,
maxY: this.y + halfHeight + padY,
};
}
/** Container offset that places the camera centre at the middle of the viewport. */
containerPosition(viewport: Viewport): { x: number; y: number } {
return {
x: viewport.width / 2 - this.x * this.zoom,
y: viewport.height / 2 + this.y * this.zoom,
};
}
}
export function rectsIntersect(a: WorldRect, b: WorldRect): boolean {
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
}
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
@@ -0,0 +1,207 @@
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;
}
interface ChunkState {
entry: ChunkIndexEntry;
rect: WorldRect;
data?: MapChunk;
graphics?: ChunkGraphics;
/** Profile the current graphics were built for; a mismatch means they need redrawing. */
drawnWith?: string;
request?: AbortController;
failed?: boolean;
}
/** How far past the viewport chunks are fetched, as a fraction of the viewport size. */
const PREFETCH_PADDING = 0.35;
/** Graphics survive a little further out than that, so small pans do not thrash the renderer. */
const RETAIN_PADDING = 1.0;
const MAX_CONCURRENT_REQUESTS = 6;
/**
* 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,
* so panning back is instant.
*/
export class ChunkManager {
private readonly states = new Map<string, ChunkState>();
private worldId: string | null = null;
private inFlight = 0;
private pending: ChunkState[] = [];
constructor(private readonly layers: MapLayers) {}
get loadedCount(): number {
let count = 0;
for (const state of this.states.values()) if (state.data) count++;
return count;
}
get totalCount(): number {
return this.states.size;
}
get isBusy(): boolean {
return this.inFlight > 0 || this.pending.length > 0;
}
setWorld(worldId: string, index: ChunkIndexEntry[]): void {
this.clear();
this.worldId = worldId;
for (const entry of index) {
const [minX, minY, maxX, maxY] = entry.bounds;
this.states.set(key(entry.x, entry.y), {
entry,
rect: { minX, minY, maxX, maxY },
});
}
}
clear(): void {
for (const state of this.states.values()) {
state.request?.abort();
if (state.graphics) {
detach(state.graphics);
destroyChunkGraphics(state.graphics);
}
}
this.states.clear();
this.pending = [];
this.inFlight = 0;
this.worldId = null;
}
/** 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;
const prefetch = grow(visible, PREFETCH_PADDING);
const retain = grow(visible, RETAIN_PADDING);
this.pending = [];
for (const state of this.states.values()) {
const wanted = rectsIntersect(state.rect, prefetch);
if (wanted && !state.data && !state.request && !state.failed) {
this.pending.push(state);
continue;
}
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;
}
}
// Nearest chunks first: 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));
this.pump(profile);
}
private pump(profile: RenderProfile): void {
while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) {
const state = this.pending.shift()!;
void this.fetch(state, profile);
}
}
private async fetch(state: ChunkState, profile: RenderProfile): Promise<void> {
const worldId = this.worldId;
if (!worldId) return;
const controller = new AbortController();
state.request = controller;
this.inFlight++;
try {
const chunk = await api.getChunk(worldId, state.entry.x, state.entry.y, controller.signal);
// The world may have been swapped while this request was in the air.
if (this.worldId !== worldId) return;
state.data = chunk;
this.draw(state, profile);
} catch (error) {
if (!controller.signal.aborted) {
state.failed = true;
console.error(`Failed to load chunk ${state.entry.x},${state.entry.y}`, error);
}
} finally {
this.inFlight--;
state.request = undefined;
this.pump(profile);
}
}
private draw(state: ChunkState, profile: RenderProfile): void {
if (!state.data) return;
if (state.graphics) {
detach(state.graphics);
destroyChunkGraphics(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);
}
}
function detach(graphics: ChunkGraphics): void {
for (const layer of Object.values(graphics)) {
layer.removeFromParent();
}
}
function key(x: number, y: number): string {
return `${x},${y}`;
}
function grow(rect: WorldRect, fraction: number): WorldRect {
const padX = (rect.maxX - rect.minX) * fraction * 0.5;
const padY = (rect.maxY - rect.minY) * fraction * 0.5;
return {
minX: rect.minX - padX,
minY: rect.minY - padY,
maxX: rect.maxX + padX,
maxY: rect.maxY + padY,
};
}
function distanceSquared(rect: WorldRect, x: number, y: number): number {
const dx = (rect.minX + rect.maxX) / 2 - x;
const dy = (rect.minY + rect.maxY) / 2 - y;
return dx * dx + dy * dy;
}
@@ -0,0 +1,228 @@
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';
/** 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. */
key: string;
detail: DetailLevel;
/** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */
minStrokeMeters: number;
}
/** Strokes thinner than this fade into the background. */
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 {
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 };
}
export interface ChunkGraphics {
areas: Graphics;
water: Graphics;
roadCasings: Graphics;
roads: Graphics;
buildings: Graphics;
}
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),
};
}
export function destroyChunkGraphics(graphics: ChunkGraphics): void {
for (const layer of Object.values(graphics)) {
layer.destroy();
}
}
interface Ring {
outline: FlatPoints;
holes?: FlatPoints[];
}
interface EdgeStyle {
width: number;
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.
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),
);
return g;
}
function renderWater(water: WaterFeature[], profile: RenderProfile): Graphics {
const g = new Graphics();
const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline);
fillRings(g, bodies, WATER_COLOR, { width: profile.minStrokeMeters, color: WATER_EDGE_COLOR });
for (const stream of water) {
if (!stream.path) continue;
g.poly(stream.path, false).stroke({
width: Math.max(stream.width ?? 2, profile.minStrokeMeters),
color: WATER_LINE_COLORS[stream.kind] ?? WATER_COLOR,
cap: 'round',
join: 'round',
});
}
return g;
}
function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics {
const g = new Graphics();
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);
// 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,
(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);
g.stroke({
width: casing ? width + casingMargin : width,
color: casing ? ROAD_CASING_COLORS[first.class] : ROAD_COLORS[first.class],
cap: 'round',
join: 'round',
});
},
);
return g;
}
function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics {
const g = new Graphics();
const minArea = MIN_BUILDING_AREA[profile.detail];
const edge: EdgeStyle | undefined =
profile.detail === 2 ? { width: profile.minStrokeMeters * 0.8, color: BUILDING_EDGE_COLOR } : 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);
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);
}
return g;
}
/**
* Fills a batch of rings. Rings without holes share one draw call; a ring with holes needs its own, because
* each cut applies to the shape that precedes it.
*/
function fillRings(g: Graphics, rings: Ring[], color: number, edge?: EdgeStyle): void {
let solidCount = 0;
for (const ring of rings) {
if (ring.holes?.length) continue;
g.poly(ring.outline);
solidCount++;
}
if (solidCount > 0) {
g.fill({ color });
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
}
for (const ring of rings) {
if (!ring.holes?.length) continue;
g.poly(ring.outline);
for (const hole of ring.holes) {
g.poly(hole);
g.cut();
}
g.fill({ color });
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
}
}
/** 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;
while (index < items.length) {
const key = keyOf(items[index]!);
const start = index;
while (index < items.length && keyOf(items[index]!) === key) index++;
draw(items.slice(start, index), key);
}
}
+243
View File
@@ -0,0 +1,243 @@
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 { profileForZoom, type RenderProfile } from './chunkRenderer';
import { LAND_COLOR } from './style';
const OUTSIDE_COLOR = 0xd5d9d2;
const BORDER_COLOR = 0xb4bab2;
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
const CHUNK_UPDATE_INTERVAL_MS = 90;
export interface MapStatus {
zoom: number;
metersPerPixel: number;
center: { x: number; y: number };
loadedChunks: number;
totalChunks: number;
loading: boolean;
}
/**
* 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.
*/
export class MapView {
private readonly app = new Application();
private readonly root = new Container();
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 chunks = new ChunkManager(this.layers);
private host: HTMLElement | null = null;
private worldSizeMeters = 0;
private profile: RenderProfile = profileForZoom(0.1);
private cameraDirty = true;
private lastChunkUpdate = 0;
private readonly activePointers = new Map<number, { x: number; y: number }>();
private pinchDistance = 0;
onStatusChange: ((status: MapStatus) => void) | null = null;
async init(host: HTMLElement): Promise<void> {
this.host = host;
await this.app.init({
background: OUTSIDE_COLOR,
antialias: true,
resizeTo: host,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
preference: 'webgl',
});
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.app.stage.addChild(this.root);
this.attachInput(this.app.canvas);
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
new ResizeObserver(() => {
this.cameraDirty = true;
}).observe(host);
this.app.ticker.add(() => this.tick());
}
showWorld(map: WorldMap): void {
this.chunks.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.camera.fit(map.sizeMeters, this.viewport);
this.chunks.setWorld(map.id, map.chunks);
this.cameraDirty = true;
this.lastChunkUpdate = 0;
}
clear(): void {
this.chunks.clear();
this.background.clear();
this.border.clear();
this.worldSizeMeters = 0;
}
destroy(): void {
this.chunks.clear();
this.app.destroy(true, { children: true });
}
private get viewport(): Viewport {
return {
width: this.host?.clientWidth || this.app.screen.width,
height: this.host?.clientHeight || this.app.screen.height,
};
}
private tick(): void {
if (this.worldSizeMeters === 0) return;
const viewport = this.viewport;
if (this.cameraDirty) {
this.camera.clampToWorld(this.worldSizeMeters);
const position = this.camera.containerPosition(viewport);
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.drawBorder();
this.cameraDirty = false;
}
const now = performance.now();
if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) {
this.lastChunkUpdate = now;
this.chunks.update(this.camera.visibleRect(viewport), this.profile);
this.publishStatus();
}
}
private drawBorder(): void {
const half = this.worldSizeMeters / 2;
this.border
.clear()
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters)
.stroke({ width: 2 / this.camera.zoom, color: BORDER_COLOR, alignment: 1 });
}
private publishStatus(): void {
this.onStatusChange?.({
zoom: this.camera.zoom,
metersPerPixel: 1 / this.camera.zoom,
center: { x: this.camera.x, y: this.camera.y },
loadedChunks: this.chunks.loadedCount,
totalChunks: this.chunks.totalCount,
loading: this.chunks.isBusy,
});
}
private attachInput(canvas: HTMLCanvasElement): void {
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', (event) => {
canvas.setPointerCapture(event.pointerId);
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
this.pinchDistance = this.currentPinchDistance();
});
canvas.addEventListener('pointermove', (event) => {
const previous = this.activePointers.get(event.pointerId);
if (!previous) return;
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (this.activePointers.size >= 2) {
this.handlePinch();
return;
}
this.camera.translateBy(event.clientX - previous.x, event.clientY - previous.y);
this.cameraDirty = true;
});
const release = (event: PointerEvent) => {
this.activePointers.delete(event.pointerId);
this.pinchDistance = this.currentPinchDistance();
};
canvas.addEventListener('pointerup', release);
canvas.addEventListener('pointercancel', release);
canvas.addEventListener('pointerleave', release);
canvas.addEventListener(
'wheel',
(event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
// A gentle exponential keeps trackpads and mouse wheels feeling alike.
this.camera.zoomAt(
Math.exp(-event.deltaY * 0.0015),
event.clientX - rect.left,
event.clientY - rect.top,
this.viewport,
);
this.cameraDirty = true;
},
{ passive: false },
);
}
private handlePinch(): void {
const distance = this.currentPinchDistance();
if (this.pinchDistance === 0 || distance === 0) {
this.pinchDistance = distance;
return;
}
const midpoint = this.pointerMidpoint();
const rect = this.app.canvas.getBoundingClientRect();
this.camera.zoomAt(distance / this.pinchDistance, midpoint.x - rect.left, midpoint.y - rect.top, this.viewport);
this.pinchDistance = distance;
this.cameraDirty = true;
}
private currentPinchDistance(): number {
if (this.activePointers.size < 2) return 0;
const [first, second] = [...this.activePointers.values()];
return Math.hypot(second!.x - first!.x, second!.y - first!.y);
}
private pointerMidpoint(): { x: number; y: number } {
const [first, second] = [...this.activePointers.values()];
return { x: (first!.x + second!.x) / 2, y: (first!.y + second!.y) / 2 };
}
}
+169
View File
@@ -0,0 +1,169 @@
import type { AreaKind, BuildingKind, RoadClass, WaterKind } 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.
*/
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. */
export const ROAD_RANK: Record<RoadClass, number> = {
path: 0,
steps: 0,
footway: 1,
cycleway: 1,
track: 2,
service: 3,
pedestrian: 3,
railway: 4,
livingStreet: 5,
residential: 6,
unclassified: 6,
unknown: 6,
tertiary: 7,
secondary: 8,
primary: 9,
trunk: 10,
motorway: 11,
};
/** Minor paths vanish first as the camera pulls back. */
export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
path: 2,
steps: 2,
footway: 2,
cycleway: 2,
track: 1,
service: 1,
pedestrian: 1,
railway: 0,
livingStreet: 0,
residential: 0,
unclassified: 0,
unknown: 1,
tertiary: 0,
secondary: 0,
primary: 0,
trunk: 0,
motorway: 0,
};
const BUILDING_BASE = 0xdfd7cc;
const BUILDING_TALL = 0xc4b8a8;
export const BUILDING_EDGE_COLOR = 0xbdb2a3;
const BUILDING_TINTS: Partial<Record<BuildingKind, number>> = {
church: 0xd9cfc4,
school: 0xdcd6c4,
civic: 0xd8d2c6,
industrial: 0xd7d1cd,
retail: 0xe2d5cc,
commercial: 0xe0d6cd,
shed: 0xe3ddd4,
garage: 0xe3ddd4,
};
/**
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
* Anything above ~30 m is already at the darkest end of the ramp.
*/
export function buildingColor(kind: BuildingKind, height: number): number {
const base = BUILDING_TINTS[kind] ?? BUILDING_BASE;
return mix(base, BUILDING_TALL, clamp01((height - 3) / 27));
}
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;
}
+276
View File
@@ -0,0 +1,276 @@
:root {
--panel-bg: rgba(252, 252, 250, 0.94);
--panel-border: #d9d6cf;
--text: #2b2a27;
--text-muted: #75726b;
--accent: #3d6b4a;
--accent-hover: #325a3d;
--error: #a63d33;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
color: var(--text);
background: #d5d9d2;
}
#stage {
position: fixed;
inset: 0;
}
#stage canvas {
display: block;
cursor: grab;
}
#stage canvas:active {
cursor: grabbing;
}
.panel {
position: fixed;
top: 16px;
left: 16px;
width: 320px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
gap: 18px;
padding: 18px;
overflow-y: auto;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
backdrop-filter: blur(6px);
}
.panel__header h1 {
margin: 0;
font-size: 17px;
letter-spacing: 0.01em;
}
.panel__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-muted);
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.field input[type='text'],
.field input[type='number'] {
padding: 7px 9px;
font: inherit;
font-size: 13px;
color: var(--text);
background: #fff;
border: 1px solid var(--panel-border);
border-radius: 7px;
}
.field input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.field input[type='range'] {
accent-color: var(--accent);
}
.field output {
float: right;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.button {
padding: 9px 12px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 7px;
cursor: pointer;
}
.button:hover:not(:disabled) {
background: var(--accent-hover);
}
.button:disabled {
opacity: 0.55;
cursor: progress;
}
.worlds h2 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.world-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.world {
display: flex;
align-items: stretch;
gap: 4px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.world[data-active='true'] {
border-color: var(--accent);
box-shadow: inset 2px 0 0 var(--accent);
}
.world[data-status='failed'] .world__detail {
color: var(--error);
}
.world__open {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
font: inherit;
text-align: left;
background: none;
border: none;
cursor: pointer;
}
.world__open:disabled {
cursor: default;
opacity: 0.75;
}
.world__open:hover:not(:disabled) {
background: #f3f5f1;
}
.world__name {
font-size: 13px;
font-weight: 600;
}
.world__detail {
font-size: 11px;
color: var(--text-muted);
}
.world__delete {
padding: 0 10px;
font-size: 16px;
line-height: 1;
color: var(--text-muted);
background: none;
border: none;
border-left: 1px solid var(--panel-border);
cursor: pointer;
}
.world__delete:hover {
color: var(--error);
background: #faf1f0;
}
.status {
min-height: 16px;
font-size: 12px;
color: var(--text-muted);
}
.status[data-tone='error'] {
color: var(--error);
}
.status[data-tone='busy']::after {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
.hud {
position: fixed;
right: 16px;
bottom: 16px;
padding: 6px 10px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 7px;
pointer-events: none;
}
@media (max-width: 640px) {
.panel {
top: auto;
bottom: 12px;
left: 12px;
right: 12px;
width: auto;
max-height: 55vh;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from 'vite';
/**
* Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
* `services__api__<scheme>__<index>`. Outside Aspire the fallback lets a plain `npm run dev` still work.
*/
function resolveApiUrl(): string {
const fromAspire = Object.keys(process.env)
.filter((key) => key.startsWith('services__api__'))
// Ascending order puts `http` ahead of `https`; plain HTTP is the simpler hop for a local proxy.
.sort()
.map((key) => process.env[key])
.find((value): value is string => !!value);
// Matches the `http` launch profile of TheLivingWorld.Api.
return fromAspire ?? process.env.API_URL ?? 'http://localhost:5195';
}
export default defineConfig(() => {
const apiUrl = resolveApiUrl();
return {
server: {
port: process.env.PORT ? Number(process.env.PORT) : 5173,
strictPort: true,
proxy: {
'/api': {
target: apiUrl,
changeOrigin: true,
// Aspire issues a development certificate the proxy has no reason to distrust.
secure: false,
},
},
},
build: {
target: 'es2022',
sourcemap: true,
},
};
});