Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
@@ -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>
|
||||
Generated
+917
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user