feat(proj): init
@@ -0,0 +1,17 @@
|
||||
# Build context only — PBF/MBTiles must never enter the image.
|
||||
**/.git
|
||||
**/.vs
|
||||
**/.idea
|
||||
**/bin
|
||||
**/obj
|
||||
**/data
|
||||
**/*.user
|
||||
**/*.mbtiles
|
||||
**/*.osm.pbf
|
||||
**/*.jar
|
||||
**/context
|
||||
**/node_modules
|
||||
**/dist
|
||||
README.md
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copy to .env next to docker-compose.yml (never commit .env).
|
||||
# Client secret lives only here / on the host — rotate it in https://oauth.yandex.ru if it leaked.
|
||||
|
||||
YANDEX_CLIENT_ID=c76f28f3d66c447e85940a9a1f227452
|
||||
YANDEX_CLIENT_SECRET=
|
||||
YANDEX_REDIRECT_URI=https://tile-server.ru
|
||||
|
||||
POSTGRES_DB=tile_server
|
||||
POSTGRES_USER=tiles
|
||||
POSTGRES_PASSWORD=tiles
|
||||
@@ -0,0 +1,27 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.idea/
|
||||
*.user
|
||||
data/extracts/
|
||||
data/tiles/
|
||||
data/tools/
|
||||
data/state/
|
||||
data/planetiler-sources/
|
||||
data/extracts.json
|
||||
*.osm.pbf
|
||||
*.mbtiles
|
||||
*.jar
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web-demo/node_modules/
|
||||
web-demo/dist/
|
||||
.env
|
||||
data/app.db
|
||||
data/app.db-shm
|
||||
data/app.db-wal
|
||||
|
||||
web-demo/node_modules/
|
||||
web-demo/dist/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tile Server</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
|
||||
<link rel="stylesheet" href="/css/demo.css?v=6Ya05cWust_Io87Xl9FL9s7IE68WqQGiJgIdqGiFCcQ" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map" role="application" aria-label="Карта"></div>
|
||||
|
||||
<header class="hud hud--top">
|
||||
<div class="brand">
|
||||
<strong>Tile Server</strong>
|
||||
<span>MapLibre · OSM · Geofabrik</span>
|
||||
</div>
|
||||
<label class="field field--inline">
|
||||
<span>Источник</span>
|
||||
<select id="source-select"></select>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
<span>Стиль</span>
|
||||
<select id="style-select"></select>
|
||||
</label>
|
||||
<span class="pill" id="status-pill">—</span>
|
||||
<button type="button" id="sync-button">Синхронизировать</button>
|
||||
<a class="link" href="/swagger" target="_blank" rel="noreferrer">OpenAPI</a>
|
||||
</header>
|
||||
|
||||
<form class="hud hud--goto" id="goto-form">
|
||||
<div class="goto__head">
|
||||
<h1>Камера</h1>
|
||||
<p id="live-coords">—</p>
|
||||
</div>
|
||||
<div class="presets" role="group" aria-label="Быстрый переход">
|
||||
<button type="button" class="preset" data-lat="55.7558" data-lon="37.6173" data-zoom="14">Москва</button>
|
||||
<button type="button" class="preset" data-lat="54.2255" data-lon="38.4692" data-zoom="6">ЦФО</button>
|
||||
<button type="button" id="fit-bounds">По выгрузке</button>
|
||||
</div>
|
||||
<div class="goto__row">
|
||||
<label class="field">
|
||||
<span>Широта</span>
|
||||
<input id="goto-lat" name="lat" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="55.7558" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Долгота</span>
|
||||
<input id="goto-lon" name="lon" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="37.6173" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Зум</span>
|
||||
<input id="goto-zoom" name="zoom" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="14" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Перейти</button>
|
||||
<p class="error" id="goto-error" hidden></p>
|
||||
</form>
|
||||
|
||||
<aside class="hud hud--status">
|
||||
<details>
|
||||
<summary>Воркер</summary>
|
||||
<dl>
|
||||
<div><dt>Задача</dt><dd id="sync-running">—</dd></div>
|
||||
<div><dt>Источник</dt><dd id="source-state">—</dd></div>
|
||||
<div><dt>Скачан</dt><dd id="source-downloaded">—</dd></div>
|
||||
<div><dt>Собран</dt><dd id="source-built">—</dd></div>
|
||||
<div><dt>Следующий запуск</dt><dd id="sync-next">—</dd></div>
|
||||
</dl>
|
||||
<p class="error" id="sync-error" hidden></p>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
|
||||
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
|
||||
<script src="/js/demo.js?v=e8nXWE8gS4oY5yeuvHvv2kHwA8dfaz4BWcupNqMCthY"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,996 @@
|
||||
{
|
||||
"name": ".tmp-tiles",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"puppeteer-core": "^23.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
|
||||
"integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"progress": "^2.0.3",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"semver": "^7.6.3",
|
||||
"tar-fs": "^3.0.6",
|
||||
"unbzip2-stream": "^1.4.3",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/cjs/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
|
||||
"integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native-b4a": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-b4a": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
|
||||
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
|
||||
"integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-stream": "^2.6.4",
|
||||
"bare-url": "^2.2.2",
|
||||
"fast-fifo": "^1.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
|
||||
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/bare-stream": {
|
||||
"version": "2.13.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
|
||||
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.8.1",
|
||||
"streamx": "^2.25.0",
|
||||
"teex": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*",
|
||||
"bare-buffer": "*",
|
||||
"bare-events": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-events": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-url": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
|
||||
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
|
||||
"integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "3.0.1",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
"integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ast-types": "^0.13.4",
|
||||
"escodegen": "^2.1.0",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1367902",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
|
||||
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
|
||||
"integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-ftp": "^5.0.2",
|
||||
"data-uri-to-buffer": "^6.0.2",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/netmask": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
|
||||
"integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
"integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/quickjs-emscripten": "^0.23.0",
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"get-uri": "^6.0.1",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"degenerator": "^5.0.0",
|
||||
"netmask": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
|
||||
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"http-proxy-agent": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lru-cache": "^7.14.1",
|
||||
"pac-proxy-agent": "^7.1.0",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "23.11.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz",
|
||||
"integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "2.6.1",
|
||||
"chromium-bidi": "0.11.0",
|
||||
"debug": "^4.4.0",
|
||||
"devtools-protocol": "0.0.1367902",
|
||||
"typed-query-selector": "^2.12.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.10",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
||||
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.28.1",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
|
||||
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events-universal": "^1.0.0",
|
||||
"fast-fifo": "^1.3.2",
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
|
||||
"integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^3.1.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bare-fs": "^4.0.1",
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
|
||||
"integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"bare-fs": "^4.5.5",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/teex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"streamx": "^2.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unbzip2-stream": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
||||
"integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.2.1",
|
||||
"through": "^2.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.23.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"puppeteer-core": "^23.11.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
|
||||
<style>html,body,#map{margin:0;height:100%;width:100%}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<pre id="log" style="position:fixed;left:8px;bottom:8px;z-index:5;background:#000c;color:#0f0;max-height:40%;overflow:auto;padding:8px;font:12px monospace;"></pre>
|
||||
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
|
||||
<script>
|
||||
const logEl = document.getElementById("log");
|
||||
const log = (m) => { logEl.textContent += m + "\n"; console.log(m); };
|
||||
const mode = new URLSearchParams(location.search).get("mode") || "plain";
|
||||
(async () => {
|
||||
const style = await (await fetch("https://tile-server.ru/api/v1/styles/osm-bright?source=central-fed-district")).json();
|
||||
log("mode=" + mode + " maxzoom=" + style.sources.openmaptiles.maxzoom);
|
||||
log("tiles=" + style.sources.openmaptiles.tiles);
|
||||
if (mode === "noglyphs") {
|
||||
delete style.glyphs;
|
||||
style.layers = style.layers.filter((l) => l.type !== "symbol");
|
||||
}
|
||||
if (mode === "wateronly") {
|
||||
delete style.glyphs;
|
||||
style.layers = [
|
||||
{ id: "background", type: "background", paint: { "background-color": "#f4f1ea" } },
|
||||
{ id: "water", type: "fill", source: "openmaptiles", "source-layer": "water", paint: { "fill-color": "#0077be" } }
|
||||
];
|
||||
}
|
||||
const opts = {
|
||||
container: "map",
|
||||
style,
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: Number(new URLSearchParams(location.search).get("z") || 14),
|
||||
maxZoom: 18
|
||||
};
|
||||
if (mode === "transform") {
|
||||
opts.transformRequest = (url) => ({ url: new URL(url, window.location.origin).href });
|
||||
}
|
||||
const map = new maplibregl.Map(opts);
|
||||
window.map = map;
|
||||
map.on("error", (e) => log("ERR " + (e.error && e.error.message)));
|
||||
map.on("sourcedata", (e) => {
|
||||
if (e.isSourceLoaded) log("source loaded " + e.sourceId);
|
||||
});
|
||||
map.on("idle", () => {
|
||||
const feats = map.queryRenderedFeatures();
|
||||
const by = {};
|
||||
for (const f of feats) by[f.layer.id] = (by[f.layer.id] || 0) + 1;
|
||||
log("idle z=" + map.getZoom().toFixed(2) + " features=" + feats.length + " " + JSON.stringify(by));
|
||||
});
|
||||
})().catch((e) => log(String(e)));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
const url = process.argv[2] || "https://tile-server.ru/#14/55.7558/37.6173";
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--use-gl=angle", "--enable-webgl"]
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const wrap = () => {
|
||||
if (!window.maplibregl || window.__mapHooked) {
|
||||
return;
|
||||
}
|
||||
window.__mapHooked = true;
|
||||
const Orig = window.maplibregl.Map;
|
||||
window.maplibregl.Map = function (options) {
|
||||
const inner = options.transformRequest;
|
||||
options.transformRequest = (reqUrl, resourceType) => {
|
||||
console.log("TRANSFORM", resourceType, reqUrl);
|
||||
return inner ? inner(reqUrl, resourceType) : { url: reqUrl };
|
||||
};
|
||||
const map = new Orig(options);
|
||||
map.on("error", (e) => {
|
||||
const err = e.error;
|
||||
console.log("MAPERR", err && (err.message || String(err)), err && err.status, err && err.url);
|
||||
});
|
||||
window.__map = map;
|
||||
return map;
|
||||
};
|
||||
};
|
||||
setInterval(wrap, 5);
|
||||
});
|
||||
page.on("console", (msg) => console.log("CONSOLE", msg.type(), msg.text()));
|
||||
page.on("pageerror", (err) => console.log("PAGEERROR", err.message));
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
const info = await page.evaluate(async () => {
|
||||
const map = window.__map;
|
||||
const layers = ["water", "building", "transportation", "boundary", "place"];
|
||||
const source = {};
|
||||
const rendered = {};
|
||||
if (map) {
|
||||
for (const layer of layers) {
|
||||
try {
|
||||
source[layer] = map.querySourceFeatures("openmaptiles", { sourceLayer: layer }).length;
|
||||
} catch (e) {
|
||||
source[layer] = String(e);
|
||||
}
|
||||
}
|
||||
const feats = map.queryRenderedFeatures();
|
||||
for (const f of feats) {
|
||||
rendered[f.layer.id] = (rendered[f.layer.id] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const cacheInfo = [];
|
||||
try {
|
||||
const caches = map.style.sourceCaches || map.style._sourceCaches;
|
||||
const cache = caches.openmaptiles || caches["openmaptiles"];
|
||||
const tiles = cache._tiles || cache.tiles || {};
|
||||
for (const [id, tile] of Object.entries(tiles)) {
|
||||
cacheInfo.push({
|
||||
id,
|
||||
state: tile.state,
|
||||
keys: Object.keys(tile).sort(),
|
||||
raw: tile.rawTileData ? tile.rawTileData.byteLength : null,
|
||||
latest: tile.latestRawTileData ? tile.latestRawTileData.byteLength : null,
|
||||
vt: tile.vtLayers ? Object.keys(tile.vtLayers) : null,
|
||||
idx: Boolean(tile.latestFeatureIndex)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
cacheInfo.push({ err: String(e) });
|
||||
}
|
||||
const probeFetch = await fetch("https://tile-server.ru/api/v1/tiles/central-fed-district/14/9904/5121.pbf?v=2");
|
||||
const probeBuf = await probeFetch.arrayBuffer();
|
||||
return {
|
||||
hash: location.hash,
|
||||
live: document.getElementById("live-coords")?.textContent,
|
||||
areTilesLoaded: map.areTilesLoaded(),
|
||||
source,
|
||||
rendered,
|
||||
renderedTotal: map ? map.queryRenderedFeatures().length : null,
|
||||
cacheInfo,
|
||||
mainFetch: {
|
||||
status: probeFetch.status,
|
||||
type: probeFetch.headers.get("content-type"),
|
||||
encoding: probeFetch.headers.get("content-encoding"),
|
||||
len: probeBuf.byteLength
|
||||
}
|
||||
};
|
||||
});
|
||||
console.log("INFO", JSON.stringify(info));
|
||||
await page.screenshot({ path: "c:/Users/ggpo1/Desktop/tile-server/.tmp-tiles/shots/prod-hook.png" });
|
||||
await browser.close();
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tile Server UI preview</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
|
||||
<link rel="stylesheet" href="/src/TileServer.Api/wwwroot/css/demo.css" />
|
||||
<style>
|
||||
#map { background: #d8d2c4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<header class="hud hud--top">
|
||||
<div class="brand">
|
||||
<span class="brand__mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.95"/>
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.28"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand__text">
|
||||
<strong>Tile Server</strong>
|
||||
<span>OSM · MapLibre · Geofabrik</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<label class="field field--inline">
|
||||
<span>Источник</span>
|
||||
<select><option>Центральный федеральный округ</option></select>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
<span>Стиль</span>
|
||||
<select><option>osm-bright</option></select>
|
||||
</label>
|
||||
<span class="pill pill--ok">готово</span>
|
||||
<button type="button" class="btn btn--accent">Синхронизировать</button>
|
||||
<a class="link" href="#">OpenAPI</a>
|
||||
</div>
|
||||
</header>
|
||||
<form class="hud hud--goto">
|
||||
<div class="goto__head">
|
||||
<div>
|
||||
<h1>Камера</h1>
|
||||
<p class="coords">55.75580°<span class="coords__sep">·</span>37.61730°<span class="coords__sep">z</span>14.00</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="presets">
|
||||
<button type="button" class="preset">Москва</button>
|
||||
<button type="button" class="preset">ЦФО</button>
|
||||
<button type="button" class="preset preset--ghost">По выгрузке</button>
|
||||
</div>
|
||||
<div class="goto__row">
|
||||
<label class="field"><span>Широта</span><input value="55.75580" /></label>
|
||||
<label class="field"><span>Долгота</span><input value="37.61730" /></label>
|
||||
<label class="field field--zoom"><span>Зум</span><input value="14.00" /></label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--accent btn--block">Перейти</button>
|
||||
</form>
|
||||
<aside class="hud hud--status is-ready">
|
||||
<details open>
|
||||
<summary><span class="worker__pulse"></span> Воркер</summary>
|
||||
<dl>
|
||||
<div><dt>Задача</dt><dd>ожидание</dd></div>
|
||||
<div><dt>Источник</dt><dd>готово</dd></div>
|
||||
<div><dt>Собран</dt><dd>21.09.2026, 23:25</dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
</aside>
|
||||
<div class="maplibregl-ctrl-top-right">
|
||||
<div class="maplibregl-ctrl maplibregl-ctrl-group">
|
||||
<button type="button">+</button>
|
||||
<button type="button">−</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
const url = process.argv[2] || "http://127.0.0.1:8766/.tmp-tiles/ui-preview.html";
|
||||
const out = process.argv[3] || "c:/Users/ggpo1/Desktop/tile-server/.tmp-tiles/shots/ui-desktop.png";
|
||||
const width = Number(process.argv[4] || 1440);
|
||||
const height = Number(process.argv[5] || 900);
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
headless: "new",
|
||||
args: ["--no-sandbox"]
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
||||
await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await page.screenshot({ path: out });
|
||||
await browser.close();
|
||||
console.log("saved", out, width, height);
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:58 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 8076
|
||||
Connection: keep-alive
|
||||
Age: 32
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:58 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 6008
|
||||
Connection: keep-alive
|
||||
Age: 31
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:59 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 456018
|
||||
Connection: keep-alive
|
||||
Age: 29
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:59 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 149481
|
||||
Connection: keep-alive
|
||||
Age: 29
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:59 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 21408
|
||||
Connection: keep-alive
|
||||
Age: 31
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
HTTP/1.1 200 OK
|
||||
Server: nginx/1.24.0 (Ubuntu)
|
||||
Date: Sun, 20 Sep 2026 20:54:59 GMT
|
||||
Content-Type: application/vnd.mapbox-vector-tile
|
||||
Content-Length: 97585
|
||||
Connection: keep-alive
|
||||
Age: 30
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS frontend
|
||||
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=web /web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY Directory.Build.props TileServer.sln ./
|
||||
COPY src/ src/
|
||||
COPY tests/ tests/
|
||||
RUN dotnet publish src/TileServer.Api/TileServer.Api.csproj -c Release -o /app/publish --no-self-contained
|
||||
|
||||
FROM eclipse-temurin:21-jre-jammy AS jre
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=jre /opt/java/openjdk /opt/java/openjdk
|
||||
ENV JAVA_HOME=/opt/java/openjdk
|
||||
ENV PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
COPY --from=build /app/publish .
|
||||
COPY --from=web /web/dist ./wwwroot/app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV TileServer__DataDirectory=/data
|
||||
ENV TileServer__JavaPath=/opt/java/openjdk/bin/java
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
ENTRYPOINT ["dotnet", "TileServer.Api.dll"]
|
||||
@@ -0,0 +1,123 @@
|
||||
# Tile Server
|
||||
|
||||
ASP.NET Core сервер векторных тайлов для клиентов **MapLibre GL**.
|
||||
|
||||
Ежедневно (и при старте) воркер:
|
||||
|
||||
1. Скачивает все настроенные OSM-extract’ы с Geofabrik (и любых других HTTP-ссылок на `.osm.pbf`).
|
||||
2. Собирает из них OpenMapTiles MBTiles через [planetiler-openmaptiles](https://github.com/openmaptiles/planetiler-openmaptiles).
|
||||
3. Отдаёт тайлы, TileJSON и стили карты.
|
||||
|
||||
## Что нужно
|
||||
|
||||
- .NET 8 SDK
|
||||
- **Java 21+** в `PATH` (или путь в `TileServer:JavaPath`) — без Java тайлы не соберутся, уже готовые MBTiles сервер всё равно отдаст
|
||||
- Диск: 5–10× размер PBF. ЦФО до **z15** — ориентир несколько ГБ готового MBTiles плюс столько же на staging во время сборки
|
||||
- RAM: ориентир `TileServer:JvmMaxHeap` ≈ 0.5× размер PBF (по умолчанию `8g`)
|
||||
|
||||
Тайлы собираются через OpenMapTiles. Схема рассчитана на z0–z14 (дефолт MapTiler/Planetiler). Жёсткий потолок **planetiler-openmaptiles 3.16 — z15**; z16+ этот JAR не умеет. Ближе z15 MapLibre overzoom’ит те же тайлы до z18.
|
||||
|
||||
Первая сборка ЦФО занимает часы. Для быстрой проверки временно поставьте Monaco:
|
||||
|
||||
```json
|
||||
"Url": "https://download.geofabrik.de/europe/monaco-latest.osm.pbf"
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
dotnet run --project src/TileServer.Api
|
||||
```
|
||||
|
||||
Откройте http://localhost:5088 — тестовая карта, выбор источника и стиля.
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Сервис слушает **51300** (внутри контейнера 8080). Данные (PBF, MBTiles, стили, ключи) — Docker volume `tile-server-data`, деплой кода его не затирает. Не вызывайте `docker compose down -v`.
|
||||
|
||||
Если на сервере уже есть `./data`, один раз перенесите:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$(pwd)/data:/from:ro" -v tile-server-data:/to alpine sh -c "cp -a /from/. /to/"
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/tiles/{source}/{z}/{x}/{y}.pbf` | векторный тайл (MapLibre XYZ) |
|
||||
| `GET` | `/api/v1/tiles/{source}.json` | TileJSON |
|
||||
| `GET` | `/api/v1/styles` | список стилей |
|
||||
| `GET` | `/api/v1/styles/{name}?source={source}` | MapLibre style JSON |
|
||||
| `PUT` | `/api/v1/styles/{name}` | добавить/обновить стиль |
|
||||
| `GET` | `/api/v1/sources` | extract’ы и статус воркера |
|
||||
| `PUT` | `/api/v1/sources/{id}` | добавить регион (ещё одна ссылка на PBF) |
|
||||
| `POST` | `/api/v1/sync` | прогнать все extract’ы сейчас |
|
||||
| `POST` | `/api/v1/sync/{id}` | прогнать один extract |
|
||||
| `GET` | `/` | демо MapLibre |
|
||||
|
||||
Алиасы без `/api/v1`: `/tiles/{source}/{z}/{x}/{y}.pbf`.
|
||||
|
||||
## Несколько регионов
|
||||
|
||||
В `appsettings.json` → `TileServer:Extracts` можно перечислить сколько угодно выгрузок. Воркер проходит **все** `Enabled: true` по очереди (чтобы не упереться в RAM).
|
||||
|
||||
```json
|
||||
"Extracts": [
|
||||
{
|
||||
"Id": "central-fed-district",
|
||||
"Name": "Центральный федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf",
|
||||
"Center": [37.6173, 55.7558]
|
||||
},
|
||||
{
|
||||
"Id": "northwestern-fed-district",
|
||||
"Name": "Северо-Западный федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/northwestern-fed-district-latest.osm.pbf",
|
||||
"Center": [30.3141, 59.9386]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
То же самое в runtime: `PUT /api/v1/sources/{id}` (пишет `data/extracts.json`, переживает рестарт).
|
||||
|
||||
Расписание: cron `TileServer:Sync:Cron`, по умолчанию `0 3 * * *` UTC. Если PBF не изменился (HTTP 304) и MBTiles уже есть — пересборка пропускается, **кроме случая когда в конфиге MaxZoom больше, чем в текущем тайсете** (после поднятия z14→z15 сборка пойдёт сама).
|
||||
|
||||
Клиенты в TileJSON/стиле видят maxzoom собранной пирамиды (14/15): MapLibre сам растягивает эти тайлы ближе. Если кто-то всё же запросит z16–18, сервер нарежет их из родителя. Вне ЦФО отдаётся 204, не 404.
|
||||
|
||||
## Стили
|
||||
|
||||
Кладёте `data/styles/{name}.json` (MapLibre Style Spec v8, схема слоёв OpenMapTiles) или делаете `PUT /api/v1/styles/{name}`.
|
||||
|
||||
Клиент запрашивает стиль по имени:
|
||||
|
||||
```
|
||||
GET /api/v1/styles/osm-bright?source=central-fed-district
|
||||
```
|
||||
|
||||
Сервер подставляет TileJSON выбранного источника. Из коробки: `osm-bright`, `dark`, `positron`.
|
||||
|
||||
Пример источника в MapLibre:
|
||||
|
||||
```js
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style: "http://localhost:5088/api/v1/styles/osm-bright?source=central-fed-district",
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 6
|
||||
});
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Api контроллеры, демо, ProblemDetails
|
||||
Application sync, tiles, styles, extracts
|
||||
Domain extract, tile coordinate, ошибки
|
||||
Infrastructure загрузка PBF, Planetiler, MBTiles/SQLite, файловые стили, hosted worker
|
||||
```
|
||||
|
||||
Данные по умолчанию: `data/` в корне репозитория (`../../data` относительно проекта API). В проде задайте `TileServer__DataDirectory`.
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileServer.Domain", "src\TileServer.Domain\TileServer.Domain.csproj", "{29E1DF49-5C50-418B-B81A-4EB3715CE486}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileServer.Application", "src\TileServer.Application\TileServer.Application.csproj", "{C79FA99D-7026-4A83-AADE-12361A6A64BF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileServer.Infrastructure", "src\TileServer.Infrastructure\TileServer.Infrastructure.csproj", "{20C71429-D816-4C4D-B0DD-3FE66850056E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileServer.Api", "src\TileServer.Api\TileServer.Api.csproj", "{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileServer.UnitTests", "tests\TileServer.UnitTests\TileServer.UnitTests.csproj", "{D109EAB4-7F39-4343-9E67-DBD85483597D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|x64.Build.0 = Release|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{29E1DF49-5C50-418B-B81A-4EB3715CE486} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{C79FA99D-7026-4A83-AADE-12361A6A64BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{20C71429-D816-4C4D-B0DD-3FE66850056E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{5EE05107-3846-41FE-86D4-B4AB9D66EE9C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{D109EAB4-7F39-4343-9E67-DBD85483597D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,25 @@
|
||||
# 2026-09-20 — Тайловый сервер на C#
|
||||
|
||||
Собрано с нуля: ASP.NET Core 8, Clean Architecture.
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Воркер `MapSyncHostedService`: cron раз в сутки (`0 3 * * *` UTC) + прогон при старте.
|
||||
- Для каждого extract из `TileServer:Extracts` (и `data/extracts.json`): скачивание `.osm.pbf` с Geofabrik (If-None-Match / 304), сборка OpenMapTiles MBTiles через `planetiler-openmaptiles` (Java 21+).
|
||||
- По умолчанию один источник: `central-fed-district`. Можно добавлять сколько угодно регионов — воркер проходит все по очереди.
|
||||
- `GET /api/v1/tiles/{source}/{z}/{x}/{y}.pbf` — MapLibre XYZ, TMS Y-flip, gzip как в MBTiles.
|
||||
- `GET /api/v1/styles/{name}?source=...` — стили по имени; `PUT` и файлы `data/styles/*.json`.
|
||||
- Демо: `GET /` MapLibre GL, выбор источника/стиля, статус воркера.
|
||||
- Сборка тайлов не на чистом C#: генерация схемы OpenMapTiles — Planetiler. C# оркестрирует download/build/serve.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
- `dotnet build` / 9 unit-тестов — ок.
|
||||
- Локальный прогон API: демо 200, стили osm-bright/dark/positron, TileJSON, 204 на отсутствующий тайл, PUT второго extract, 404 на неизвестный стиль.
|
||||
- Браузерных MCP в сессии не было — проверка через HTTP.
|
||||
|
||||
## Важно для следующего запуска
|
||||
|
||||
- Без Java 21 воркер скачает PBF, но не соберёт тайлы.
|
||||
- Первая сборка ЦФО — часы и много диска/RAM (`TileServer:JvmMaxHeap`).
|
||||
- `TileServer:Sync:RunOnStartup` по умолчанию `true`.
|
||||
@@ -0,0 +1,6 @@
|
||||
# 2026-09-20 — docker-compose для сервера, порт 51300
|
||||
|
||||
- `docker-compose.yml`: `51300:8080`, `restart: unless-stopped`, `./data:/data`, healthcheck `/health/live`, лимит RAM 12g, логи json-file.
|
||||
- Dockerfile: `curl` в runtime-образе под healthcheck.
|
||||
- `.dockerignore`: `data/`, PBF, MBTiles, bin/obj не попадают в контекст сборки.
|
||||
- README: URL деплоя обновлён на порт 51300.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 2026-09-20 — Planetiler: Unsupported format building
|
||||
|
||||
На сервере compose отработал до сборки тайлов и упал.
|
||||
|
||||
- PBF ЦФО скачался (877 MB), JAR Planetiler тоже.
|
||||
- Падение: `IllegalArgumentException: Unsupported format building` на `/data/tiles/central-fed-district.mbtiles.building`.
|
||||
- Planetiler смотрит последнее расширение файла. `.building` не формат архива.
|
||||
- Фикс: staging `*.building.mbtiles`, вывод через `--output`, `--tmpdir` в `/data/tmp`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 — Planetiler: MapTiler CDN timeout
|
||||
|
||||
После фикса `.mbtiles` сборка дошла до скачивания sidecar-данных.
|
||||
|
||||
- `water-polygons` с osmdata.openstreetmap.de — ок (929 MB).
|
||||
- `natural_earth` и `lake_centerlines` с `dev.maptiler.download` — ~25 КБ и Read timed out (30s × 5).
|
||||
- Фикс: зеркала naciscdn + GitHub osm-lakelines, предзагрузка нашим HttpClient, `--http_timeout=15m`, обрезки < min size удаляются.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 — «зависло» на Natural Earth
|
||||
|
||||
Фронт Building / «идёт синхронизация» / «следующий запуск нет» — это не сборка тайлов.
|
||||
|
||||
Лог оборвался после HTTP 200 на `naciscdn.org/.../natural_earth_vector.sqlite.zip`. Тело не шло, HttpClient без таймаута ждал вечно. NextScheduledAt не ставился, пока RunOnStartup не закончится.
|
||||
|
||||
Фикс: stall 45s, зеркала, skip PBF если размер совпал, статус PreparingSources, next cron сразу при старте.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 — пустая карта: mixed content
|
||||
|
||||
Тайлы собрались (1.3GB). Браузер блокировал `http://tile-server.ru/api/v1/tiles/...json` на HTTPS-странице.
|
||||
|
||||
Приложение за nginx видит Scheme=http и вшивало http:// в style/TileJSON.
|
||||
|
||||
Фикс: root-relative URL (`/api/v1/tiles/...`), ForwardedHeaders, опция PublicBaseUrl, transformRequest на демо как страховка.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — данные в named volume
|
||||
|
||||
`./data:/data` заменён на volume `tile-server-data`. Деплой/`compose up --build` его не трогает. DataProtection keys тоже в `/data/aspnet-keys`.
|
||||
|
||||
На сервере с уже собранными тайлами сначала скопировать старый `./data` в volume, иначе контейнер увидит пустой `/data` и начнёт сборку заново.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 — MapLibre: relative tile URL invalid
|
||||
|
||||
Тайлы Ready, TileJSON bounds ЦФО ок. Пустая карта: `tiles: ["/api/v1/tiles/...pbf"]`.
|
||||
|
||||
Воркер MapLibre делает `new Request(url)` — относительный путь не URL.
|
||||
|
||||
Фикс: абсолютный `https://tile-server.ru` (PublicBaseUrl + scheme/host), demo `transformRequest` через `new URL(url, location.origin)`.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — карта пустеет ближе 1 км
|
||||
|
||||
Тайлы Planetiler до z14 (~1 км в Москве). Дальше MapLibre без source.maxzoom просит z15+; 204 = «пустой тайл», не overzoom.
|
||||
|
||||
Фикс: в стиле `tiles` + `maxzoom: 14` (без TileJSON url), демо не делает setStyle каждые 15 с, missing tile → 404.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — maxzoom 15
|
||||
|
||||
z14 был дефолт OpenMapTiles, не потолок данных. Planetiler 0.9.3 (jar v3.16) жёстко режет на z15.
|
||||
|
||||
Конфиг и extract → 15, `--render_maxzoom=15`. Синк пересобирает, если в MBTiles maxzoom меньше конфига. z16 этим стеком нельзя.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — серверный overzoom MVT
|
||||
|
||||
Клиентский overzoom нельзя требовать от всех. Отдать parent.pbf как child нельзя: MVT привязан к экстенту тайла.
|
||||
|
||||
Сервер для z > dataMaxzoom (15) клипает геометрию в координаты ребёнка, gzip, кэш. TileJSON/style maxzoom=18.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 2026-09-20 — переход к lat/lon/zoom
|
||||
|
||||
В демо-панели форма «Перейти к точке»: широта, долгота, зум, flyTo. Запятая как десятичный разделитель ок.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — пустые тайлы Москвы
|
||||
|
||||
Проверил прод: z6–z14 Москвы 200, здания/дороги на месте. Пусто из‑за maxzoom=18 в стиле: MapLibre просит z15–18 вместо overzoom z14, плюс 404 JSON на клетки вне ЦФО.
|
||||
|
||||
Фикс: в TileJSON/стиле снова data maxzoom; missing → 204. Серверный overzoom остаётся, если клиент всё же запросит z>14.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 2026-09-20 — пересборка demo
|
||||
|
||||
Карта на весь экран, HUD поверх: источник/стиль, камера (пресеты Москва/ЦФО, lat lon zoom), live z/lat/lon, hash `#z/lat/lon`. Воркер свёрнут. Поллинг больше не сбрасывает камеру при смене стиля.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — пустая карта на z14 Москва
|
||||
|
||||
Камера верная, фон osm-bright, слоёв нет. Тайл 14/9904/5121 на проде 200/gzip.
|
||||
|
||||
Фикс: отдать сырой MVT без Content-Encoding (nginx+gzip ломает воркер MapLibre); убрать bounds из стиля; HUD не писать 0,0 до moveend; `?v=2` чтобы сбросить кэш gzip-ответов.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 2026-09-20 — пустая карта после z2
|
||||
|
||||
Мир на z0–z1 рисуется, Москва z14 нет. Тайл 14/9904/5121 на проде 456 KB, в Chrome main-thread fetch 200, а воркер MapLibre получает пустые loaded-тайлы.
|
||||
|
||||
Причина: один SqliteConnection на все запросы. z0 — один запрос, ок; z2+ — пачка параллельных чтений, SQLite не thread-safe → 204. Фикс: соединение из пула на каждый GetTile. HUD: не блокировать синкиз-за кнопок в форме. Шрифты Noto Sans (Open Sans 404 на demotiles).
|
||||
@@ -0,0 +1,3 @@
|
||||
# 2026-09-21 — UI демо
|
||||
|
||||
HUD больше не тянется на всю ширину. Стекло-острова: тулбар слева сверху, камера слева снизу, воркер справа снизу. Те же id для demo.js. Статус — пилюля с точкой, шрифты tabular, MapLibre-контролы в том же тёмном стекле.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 2026-09-21 — кэш тайлов nginx
|
||||
|
||||
204 больше не кэшируем (Cache-Control: no-store). 200 pbf — week immutable. Nginx: отдельный location на `.pbf`, proxy_cache, ключ с `$args` (`?v=`), не gzip в location тайлов.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Аккаунты, токены и SPA
|
||||
|
||||
Поверх тайлового пайплайна: Яндекс OAuth, персональные стили/URL, учёт запросов, Vite-кабинет на том же origin.
|
||||
|
||||
## Данные
|
||||
|
||||
- EF Core SQLite, файл `/data/app.db` (`IDataPaths.AppDatabase`).
|
||||
- Таблицы: `Users`, `ApiTokens` (только SHA-256 hash + prefix `ts_...`), `UserStyles`, `UsageDaily`.
|
||||
- Миграция `InitialAccounts`, `Database.Migrate()` на старте.
|
||||
|
||||
## Auth
|
||||
|
||||
- `GET /api/v1/auth/yandex/start` → 302 на oauth.yandex.ru.
|
||||
- SPA ловит `?code=&state=` на `/`, `POST /api/v1/auth/yandex`.
|
||||
- Cookie `ts_session` HttpOnly, SameSite=Lax, Secure=SameAsRequest.
|
||||
- Кабинет: cookie. Выдача тайлов/стилей: `?token=` или `Authorization: Bearer`.
|
||||
- Секрет Яндекса только в env: `TileServer__Yandex__ClientId|ClientSecret|RedirectUri`. Не коммитить.
|
||||
|
||||
## Выдача и usage
|
||||
|
||||
- `/u/{slug}/tiles/{source}/{z}/{x}/{y}.pbf?token=`
|
||||
- `/u/{slug}/tiles/{source}.json?token=`
|
||||
- `/u/{slug}/styles/{name}?source=&token=`
|
||||
- Счётчик в `ConcurrentDictionary`, flush каждые 5 с в `UsageDaily`.
|
||||
- Публичные `/api/v1/tiles` и `/api/v1/styles` без счётчика.
|
||||
|
||||
## Стили
|
||||
|
||||
- Клон пресета: `POST /api/v1/me/styles`.
|
||||
- Bind персонального стиля: `tiles[0]` = `/u/{slug}/tiles/{source}/...pbf?token=`.
|
||||
- Свои имена перекрывают пресеты.
|
||||
|
||||
## Фронт и деплой
|
||||
|
||||
- `web/`: Vite + React + styled-components, тема HUD (`#e8923d`).
|
||||
- Docker: stage `node:22` → `wwwroot/app`. `/` — SPA, `/demo` — старое Razor-демо.
|
||||
- Compose: `YANDEX_CLIENT_ID` / `YANDEX_CLIENT_SECRET` с хоста.
|
||||
|
||||
## Тесты
|
||||
|
||||
26 unit-тестов: hash/verify/revoked, slug, bind `/u/{slug}/` + token, батч usage.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Фронт в compose на 5250
|
||||
|
||||
Сервис `tile-web`: тот же `Dockerfile`, target `frontend` (nginx + Vite `dist`). Порт хоста **5250**.
|
||||
|
||||
`/api` и `/u` проксируются на `tile-server:8080`, чтобы кабинет на `:5250` ходил в API с cookie на том же origin. Статика — SPA fallback на `index.html`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Яндекс OAuth как в please_pay_me_bot
|
||||
|
||||
400 `Yandex OAuth is not configured` был из-за пустых `${YANDEX_CLIENT_ID:-}` в контейнере.
|
||||
|
||||
Как в боте:
|
||||
|
||||
- плоские env `YANDEX_CLIENT_ID` / `YANDEX_CLIENT_SECRET` / `YANDEX_REDIRECT_URI` (плюс `TileServer__Yandex__*`);
|
||||
- секрет только в `.env` на хосте (`.env.example` без секрета);
|
||||
- SPA берёт `client_id` с `GET /api/v1/auth/providers` и сама открывает `oauth.yandex.ru/authorize`;
|
||||
- обмен кода: `POST /api/v1/auth/yandex` с тем же `redirectUri`.
|
||||
|
||||
На сервере: скопировать `.env.example` → `.env`, вписать секрет, `docker compose up -d --build`.
|
||||
@@ -0,0 +1,8 @@
|
||||
# UI-kit и форма входа как в please_pay_me_bot
|
||||
|
||||
Скопирован design system (`design/`, `ui/`, `components/ui`) и правовые страницы.
|
||||
|
||||
- Вход: `AuthLayout` + чекбоксы оферты/согласия + кнопка «Войти через Яндекс» (disabled без галочек).
|
||||
- Документы: `/legal/offer|privacy|consent|cookies`, футер, cookie-баннер.
|
||||
- Оператор тот же ИП; предмет оферты — тайлы OSM / стили / токены, без ЮKassa и APK.
|
||||
- Кабинет в `AppShell` того же UI-kit.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Локальный .env для проверки Яндекс OAuth
|
||||
|
||||
Рядом с compose лежит `.env` (в gitignore) с текущими YANDEX_* — чтобы проверить вход. После проверки сменить секрет в кабинете OAuth.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Учётки на PostgreSQL
|
||||
|
||||
Кабинет (Users, ApiTokens, UserStyles, UsageDaily) переведён с SQLite `app.db` на Postgres.
|
||||
|
||||
- Compose: сервис `db` (`postgres:16-alpine`), хост **5433** → 5432.
|
||||
- Volume `tile-server-pg` (отдельно от `tile-server-data`).
|
||||
- Строка: `ConnectionStrings__Accounts` / `ConnectionStrings:Accounts`.
|
||||
- MBTiles по-прежнему SQLite в `/data/tiles` — это не трогали.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Отдельный Vite demo
|
||||
|
||||
Публичная проверка тайлов/стилей/sync без кабинета и без Razor `/demo`.
|
||||
|
||||
- Проект `web-demo/`: React + MapLibre, тот же HUD (`demo.css`), публичные `/api/v1/sources|styles|tiles|sync`.
|
||||
- Локально: `npm run dev` на порту **5174**, proxy `/api` → `127.0.0.1:5088`.
|
||||
- Compose: сервис `tile-demo` (Dockerfile `target: demo`), хост **5251** → nginx 80, proxy на `tile-server:8080`.
|
||||
- Gzip выключен на `/api/v1/tiles/` и `/tiles/`.
|
||||
- Логин/токены не трогаем. Razor `/demo` оставлен как есть.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vite demo без compose
|
||||
|
||||
`tile-demo` убран из compose и Dockerfile. Демо только локально: `web-demo/` → `npm run dev` :5174.
|
||||
|
||||
Проверка личного API: slug + токен (`ts_…`) в HUD или `?slug=&token=`. Тогда стили/тайлы идут на `/u/{slug}/…`, не на публичный `/api/v1`. Токен и стиль создаются в кабинете.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vite demo → боевой API
|
||||
|
||||
`web-demo` ходит напрямую на `https://tile-server.ru` (CORS `*`). Локальный proxy на 5088 убран.
|
||||
|
||||
Переопределение: `VITE_API_ORIGIN`. Личный режим: slug + `ts_…` → `/u/{slug}/…` на том же хосте.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Публичные стили и тайлы закрыты
|
||||
|
||||
`GET/PUT /api/v1/styles`, `GET /api/v1/tiles`, алиасы `/tiles/{id}/…` отвечают 401. Карта только через `/u/{slug}/…?token=`. Кабинет `/api/v1/me/styles` не трогал. В `ExtractResponse` публичные tile URL больше не отдаются.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 502 на /u/{slug}
|
||||
|
||||
Хостовый nginx слал `/u/` на кабинет :5250. `tile-web` проксировал `/u/` на `127.0.0.1:8080` внутри своего контейнера — там нет API → 502.
|
||||
|
||||
Нужен `location /u/` → `127.0.0.1:51300` (как `/api/`). В `web/nginx.conf` fallback тоже на `http://tile-server:8080`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# 500 на стилях
|
||||
|
||||
`StylesController` закрывал публичный `/api/v1/styles` через `throw UnauthenticatedException`. ExceptionHandlerMiddleware логирует это как unhandled (снаружи 500), затем handler ставит 401.
|
||||
|
||||
Кабинет клонирует через `POST /api/v1/me/styles` — этот маршрут не закрыт. Стек указывал на `StylesController.List()` = `GET /api/v1/styles`.
|
||||
|
||||
Исправление: публичные tiles/styles отвечают 401 `ProblemDetails` без throw.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Кабинет: инструкции и редактор стилей
|
||||
|
||||
- Убран пункт «Демо карты».
|
||||
- Раздел `/guides`: шаги, URL стиля/тайлов, HTML и React, живая карта.
|
||||
- Редактор `/styles/:name`: слева свойства слоя (цвет, толщина, видимость), по центру MapLibre, справа список слоёв. Пресеты read-only.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 500 на /api/v1/me/usage
|
||||
|
||||
Npgsql/EF не транслирует `GroupBy(Date).Select(new UsageDayDto(Sum…))`. Запрос падал `InvalidOperationException` → 500.
|
||||
|
||||
Чтение строк по диапазону, агрегация по дню уже в памяти (неделя × токены — мало строк).
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Dark",
|
||||
"metadata": { "tile-server:kind": "openmaptiles" },
|
||||
"sources": {
|
||||
"openmaptiles": { "type": "vector", "url": "{{tilejson}}" }
|
||||
},
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{ "id": "background", "type": "background", "paint": { "background-color": "#1b1f24" } },
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": { "fill-color": "#243026", "fill-opacity": 0.9 }
|
||||
},
|
||||
{
|
||||
"id": "park",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "park",
|
||||
"paint": { "fill-color": "#2a382c", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "water",
|
||||
"filter": ["==", "$type", "Polygon"],
|
||||
"paint": { "fill-color": "#1a3347" }
|
||||
},
|
||||
{
|
||||
"id": "waterway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "waterway",
|
||||
"paint": { "line-color": "#1a3347", "line-width": { "base": 1.3, "stops": [[8, 0.5], [16, 4]] } }
|
||||
},
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": { "fill-color": "#2b3138", "fill-outline-color": "#3a414a" }
|
||||
},
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 12,
|
||||
"filter": ["in", "class", "minor", "service", "track"],
|
||||
"paint": { "line-color": "#3d4450", "line-width": { "base": 1.2, "stops": [[12, 0.8], [18, 8]] } }
|
||||
},
|
||||
{
|
||||
"id": "road-secondary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "primary", "secondary", "tertiary"],
|
||||
"paint": { "line-color": "#5a6270", "line-width": { "base": 1.2, "stops": [[6, 0.8], [18, 14]] } }
|
||||
},
|
||||
{
|
||||
"id": "road-motorway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "motorway", "trunk"],
|
||||
"paint": { "line-color": "#c47a3a", "line-width": { "base": 1.2, "stops": [[5, 0.8], [18, 18]] } }
|
||||
},
|
||||
{
|
||||
"id": "road-rail",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"paint": { "line-color": "#4a4450", "line-width": 0.8 }
|
||||
},
|
||||
{
|
||||
"id": "boundary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["in", "admin_level", 2, 4],
|
||||
"paint": { "line-color": "#6a7380", "line-dasharray": [3, 2], "line-width": 1, "line-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "road-name",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 12,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"symbol-placement": "line"
|
||||
},
|
||||
"paint": { "text-color": "#d7dde6", "text-halo-color": "#1b1f24", "text-halo-width": 1.2 }
|
||||
},
|
||||
{
|
||||
"id": "place-city",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "place",
|
||||
"filter": ["in", "class", "city", "town"],
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Bold"],
|
||||
"text-size": { "stops": [[4, 12], [10, 18]] }
|
||||
},
|
||||
"paint": { "text-color": "#f2f5f8", "text-halo-color": "#1b1f24", "text-halo-width": 1.6 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "OSM Bright",
|
||||
"metadata": {
|
||||
"tile-server:kind": "openmaptiles"
|
||||
},
|
||||
"sources": {
|
||||
"openmaptiles": {
|
||||
"type": "vector",
|
||||
"url": "{{tilejson}}"
|
||||
}
|
||||
},
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": { "background-color": "#f4f1ea" }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": { "fill-color": "#c6ddb4", "fill-opacity": 0.8 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-grass",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "grass"],
|
||||
"paint": { "fill-color": "#d8e8c8", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-farmland",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "farmland"],
|
||||
"paint": { "fill-color": "#e8e4c8", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-residential",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "residential"],
|
||||
"paint": { "fill-color": "#e8e0d8", "fill-opacity": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "park",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "park",
|
||||
"paint": { "fill-color": "#cdebb0", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "water",
|
||||
"filter": ["==", "$type", "Polygon"],
|
||||
"paint": { "fill-color": "#7eb6d9" }
|
||||
},
|
||||
{
|
||||
"id": "waterway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "waterway",
|
||||
"paint": {
|
||||
"line-color": "#7eb6d9",
|
||||
"line-width": { "base": 1.3, "stops": [[8, 0.5], [16, 4]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": {
|
||||
"fill-color": "#d9d0c7",
|
||||
"fill-outline-color": "#c4bbb2",
|
||||
"fill-opacity": { "base": 1, "stops": [[13, 0], [14, 1]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "transportation-tunnel",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "brunnel", "tunnel"],
|
||||
"paint": {
|
||||
"line-color": "#d0d0d0",
|
||||
"line-dasharray": [1, 1],
|
||||
"line-width": { "base": 1.4, "stops": [[6, 0.5], [14, 4], [18, 16]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-minor-casing",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 12,
|
||||
"filter": ["all", ["in", "class", "minor", "service", "track"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#cfcdca",
|
||||
"line-width": { "base": 1.2, "stops": [[12, 1.2], [18, 10]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 12,
|
||||
"filter": ["all", ["in", "class", "minor", "service", "track"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": { "base": 1.2, "stops": [[12, 0.8], [18, 8]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-tertiary-casing",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 8,
|
||||
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#c4b9a6",
|
||||
"line-width": { "base": 1.2, "stops": [[8, 1.5], [18, 14]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-tertiary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 8,
|
||||
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": { "base": 1.2, "stops": [[8, 0.8], [18, 12]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-secondary-casing",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "primary", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#d4b98a",
|
||||
"line-width": { "base": 1.2, "stops": [[6, 1.4], [18, 18]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-secondary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "primary", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#feeaa2",
|
||||
"line-width": { "base": 1.2, "stops": [[6, 0.8], [18, 14]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-motorway-casing",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "motorway", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#d05a3a",
|
||||
"line-width": { "base": 1.2, "stops": [[5, 1.6], [18, 22]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-motorway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "motorway", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#e8923d",
|
||||
"line-width": { "base": 1.2, "stops": [[5, 0.8], [18, 18]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-rail",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"paint": {
|
||||
"line-color": "#9a8f86",
|
||||
"line-width": { "base": 1.2, "stops": [[8, 0.4], [16, 2]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["in", "admin_level", 2, 4],
|
||||
"paint": {
|
||||
"line-color": "#9e8f80",
|
||||
"line-dasharray": [3, 2],
|
||||
"line-width": { "base": 1, "stops": [[2, 0.6], [10, 1.8]] },
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-name",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 12,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"symbol-placement": "line"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#4a433c",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "water-name",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "water_name",
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[10, 11], [14, 16]] }
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#3d6e8c",
|
||||
"text-halo-color": "#d6ecf7",
|
||||
"text-halo-width": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "place-village",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "place",
|
||||
"minzoom": 9,
|
||||
"filter": ["==", "class", "village"],
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#333333",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 1.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "place-town",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "place",
|
||||
"minzoom": 7,
|
||||
"filter": ["==", "class", "town"],
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[7, 12], [12, 16]] }
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#222222",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "place-city",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "city"],
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Bold"],
|
||||
"text-size": { "stops": [[4, 12], [10, 20]] }
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#111111",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 1.6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Positron",
|
||||
"metadata": { "tile-server:kind": "openmaptiles" },
|
||||
"sources": {
|
||||
"openmaptiles": { "type": "vector", "url": "{{tilejson}}" }
|
||||
},
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{ "id": "background", "type": "background", "paint": { "background-color": "#f2f4f7" } },
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["in", "class", "wood", "grass"],
|
||||
"paint": { "fill-color": "#e4eadc", "fill-opacity": 0.8 }
|
||||
},
|
||||
{
|
||||
"id": "park",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "park",
|
||||
"paint": { "fill-color": "#e6eedd" }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "water",
|
||||
"filter": ["==", "$type", "Polygon"],
|
||||
"paint": { "fill-color": "#cddceb" }
|
||||
},
|
||||
{
|
||||
"id": "waterway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "waterway",
|
||||
"paint": { "line-color": "#cddceb", "line-width": { "stops": [[8, 0.5], [16, 3]] } }
|
||||
},
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": { "fill-color": "#e6e8ec", "fill-outline-color": "#d5d8de" }
|
||||
},
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 12,
|
||||
"filter": ["in", "class", "minor", "service"],
|
||||
"paint": { "line-color": "#ffffff", "line-width": { "stops": [[12, 0.6], [18, 7]] } }
|
||||
},
|
||||
{
|
||||
"id": "road-secondary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "primary", "secondary", "tertiary"],
|
||||
"paint": { "line-color": "#ffffff", "line-width": { "stops": [[7, 0.8], [18, 12]] } }
|
||||
},
|
||||
{
|
||||
"id": "road-motorway",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "motorway", "trunk"],
|
||||
"paint": { "line-color": "#e6cfc0", "line-width": { "stops": [[5, 1], [18, 16]] } }
|
||||
},
|
||||
{
|
||||
"id": "boundary",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["in", "admin_level", 2, 4],
|
||||
"paint": { "line-color": "#b7bec8", "line-dasharray": [4, 2], "line-width": 1 }
|
||||
},
|
||||
{
|
||||
"id": "road-name",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 13,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"symbol-placement": "line"
|
||||
},
|
||||
"paint": { "text-color": "#6b7380", "text-halo-color": "#ffffff", "text-halo-width": 1.2 }
|
||||
},
|
||||
{
|
||||
"id": "place-city",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "place",
|
||||
"filter": ["in", "class", "city", "town"],
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name"], ["get", "name:latin"]],
|
||||
"text-font": ["Noto Sans Bold"],
|
||||
"text-size": { "stops": [[4, 11], [10, 16]] }
|
||||
},
|
||||
"paint": { "text-color": "#4a5160", "text-halo-color": "#ffffff", "text-halo-width": 1.6 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Production deploy.
|
||||
# API 51300 → Kestrel 8080
|
||||
# SPA 5250 → nginx (кабинет + proxy /api и /u на tile-server)
|
||||
# PG 5433 → Postgres (учётки, токены, стили, usage)
|
||||
#
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Persistent data (PBF, MBTiles, Planetiler sources, styles, keys) lives in the
|
||||
# named volume `tile-server-data`. Image rebuilds and `docker compose up` do not
|
||||
# touch it. Do NOT use `docker compose down -v` — that deletes the volume.
|
||||
#
|
||||
# If you already have files in ./data on the server, copy them once:
|
||||
# docker run --rm -v "$(pwd)/data:/from:ro" -v tile-server-data:/to alpine \
|
||||
# sh -c "cp -a /from/. /to/"
|
||||
#
|
||||
# Tune TileServer__JvmMaxHeap / mem_limit to the machine.
|
||||
# After raising MaxZoom (z14→z15) the next startup rebuilds MBTiles; keep the volume.
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: tile-server-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tile_server}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tiles}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tiles}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- tile-server-pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tiles} -d ${POSTGRES_DB:-tile_server}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
tile-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: tile-server:latest
|
||||
container_name: tile-server
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "51300:8080"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
TZ: UTC
|
||||
TileServer__DataDirectory: /data
|
||||
TileServer__JavaPath: /opt/java/openjdk/bin/java
|
||||
TileServer__JvmMaxHeap: 8g
|
||||
TileServer__Sync__RunOnStartup: "true"
|
||||
TileServer__Sync__Cron: "0 3 * * *"
|
||||
TileServer__Sync__TimeZone: UTC
|
||||
# Public origin for MapLibre (must be absolute https, not a relative path).
|
||||
TileServer__PublicBaseUrl: https://tile-server.ru
|
||||
# Yandex ID — same flat names as please_pay_me_bot. Secret only in .env.
|
||||
YANDEX_CLIENT_ID: ${YANDEX_CLIENT_ID:-c76f28f3d66c447e85940a9a1f227452}
|
||||
YANDEX_CLIENT_SECRET: ${YANDEX_CLIENT_SECRET:-}
|
||||
YANDEX_REDIRECT_URI: ${YANDEX_REDIRECT_URI:-https://tile-server.ru}
|
||||
TileServer__Yandex__ClientId: ${YANDEX_CLIENT_ID:-c76f28f3d66c447e85940a9a1f227452}
|
||||
TileServer__Yandex__ClientSecret: ${YANDEX_CLIENT_SECRET:-}
|
||||
TileServer__Yandex__RedirectUri: ${YANDEX_REDIRECT_URI:-https://tile-server.ru}
|
||||
ConnectionStrings__Accounts: >-
|
||||
Host=db;Port=5432;Database=${POSTGRES_DB:-tile_server};Username=${POSTGRES_USER:-tiles};Password=${POSTGRES_PASSWORD:-tiles}
|
||||
volumes:
|
||||
- tile-server-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 45s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
stop_grace_period: 2m
|
||||
mem_limit: 12g
|
||||
memswap_limit: 12g
|
||||
|
||||
tile-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: frontend
|
||||
image: tile-server-web:latest
|
||||
container_name: tile-server-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
tile-server:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "5250:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
tile-server-data:
|
||||
name: tile-server-data
|
||||
tile-server-pg:
|
||||
name: tile-server-pg
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
public sealed class AuthController(
|
||||
IAccountService accounts,
|
||||
IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
private const string OAuthStateCookie = "ts_oauth_state";
|
||||
|
||||
[HttpGet("providers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Providers()
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
var configured = yandex.IsConfigured;
|
||||
return Ok(new AuthProvidersResponse(
|
||||
new YandexProviderResponse(
|
||||
configured,
|
||||
configured ? yandex.ClientId : null,
|
||||
configured ? yandex.RedirectUri : null)));
|
||||
}
|
||||
|
||||
[HttpGet("yandex/start")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult StartYandex()
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
if (!yandex.IsConfigured)
|
||||
{
|
||||
throw new DomainValidationException("Yandex OAuth is not configured.");
|
||||
}
|
||||
|
||||
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
Response.Cookies.Append(OAuthStateCookie, state, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromMinutes(15),
|
||||
Path = "/"
|
||||
});
|
||||
|
||||
var url =
|
||||
"https://oauth.yandex.ru/authorize" +
|
||||
"?response_type=code" +
|
||||
$"&client_id={Uri.EscapeDataString(yandex.ClientId)}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(yandex.RedirectUri)}" +
|
||||
$"&state={Uri.EscapeDataString(state)}";
|
||||
return Redirect(url);
|
||||
}
|
||||
|
||||
[HttpPost("yandex")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ExchangeYandex([FromBody] YandexCodeRequest request, CancellationToken ct)
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
if (!yandex.IsConfigured)
|
||||
{
|
||||
throw new DomainValidationException("Yandex OAuth is not configured.");
|
||||
}
|
||||
|
||||
var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri)
|
||||
? yandex.RedirectUri
|
||||
: request.RedirectUri.Trim();
|
||||
if (!IsAllowedRedirect(yandex.RedirectUri, redirectUri))
|
||||
{
|
||||
throw new DomainValidationException("Invalid Yandex redirect_uri.");
|
||||
}
|
||||
|
||||
if (Request.Cookies.TryGetValue(OAuthStateCookie, out var expected) &&
|
||||
!string.IsNullOrEmpty(expected) &&
|
||||
!string.Equals(expected, request.State, StringComparison.Ordinal))
|
||||
{
|
||||
throw new UnauthenticatedException("OAuth state mismatch.");
|
||||
}
|
||||
|
||||
var (me, cabinetToken) = await accounts.SignInFromYandexAsync(request.Code, redirectUri, ct)
|
||||
.ConfigureAwait(false);
|
||||
var identity = new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, me.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, me.Login),
|
||||
new Claim("slug", me.Slug)
|
||||
],
|
||||
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await HttpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30)
|
||||
}).ConfigureAwait(false);
|
||||
Response.Cookies.Delete(OAuthStateCookie);
|
||||
return Ok(new AuthResponse(me, cabinetToken));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static bool IsAllowedRedirect(string configured, string incoming)
|
||||
{
|
||||
static string Normalize(string value) => value.Trim().TrimEnd('/');
|
||||
return string.Equals(Normalize(configured), Normalize(incoming), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record YandexCodeRequest(string Code, string? State, string? RedirectUri);
|
||||
|
||||
public sealed record AuthResponse(MeResponse User, string? CabinetToken);
|
||||
|
||||
public sealed record YandexProviderResponse(bool Enabled, string? ClientId, string? RedirectUri);
|
||||
|
||||
public sealed record AuthProvidersResponse(YandexProviderResponse Yandex);
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[Route("demo")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public sealed class DemoController : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
public IActionResult Index() => View();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/me")]
|
||||
public sealed class MeController(
|
||||
IAccountService accounts,
|
||||
IApiTokenService tokens,
|
||||
IUserStyleService styles,
|
||||
IUsageQuery usage) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken ct)
|
||||
=> Ok(await accounts.GetMeAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("tokens")]
|
||||
public async Task<IActionResult> ListTokens(CancellationToken ct)
|
||||
=> Ok(await tokens.ListAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPost("tokens")]
|
||||
public async Task<IActionResult> CreateToken([FromBody] CreateTokenRequest request, CancellationToken ct)
|
||||
=> Ok(await tokens.CreateAsync(SessionUser.GetId(User), request.Name ?? "Token", ct).ConfigureAwait(false));
|
||||
|
||||
[HttpDelete("tokens/{id:guid}")]
|
||||
public async Task<IActionResult> RevokeToken(Guid id, CancellationToken ct)
|
||||
{
|
||||
await tokens.RevokeAsync(SessionUser.GetId(User), id, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("styles")]
|
||||
public async Task<IActionResult> ListStyles(CancellationToken ct)
|
||||
=> Ok(await styles.ListMineAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("styles/{name}")]
|
||||
public async Task<IActionResult> GetStyle(string name, CancellationToken ct)
|
||||
=> new JsonResult(await styles.GetMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPost("styles")]
|
||||
public async Task<IActionResult> CloneStyle([FromBody] CloneStyleRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.From))
|
||||
{
|
||||
throw new DomainValidationException("Style name and source preset are required.");
|
||||
}
|
||||
|
||||
return Ok(await styles.CloneAsync(SessionUser.GetId(User), request.Name, request.From, ct)
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
[HttpPut("styles/{name}")]
|
||||
public async Task<IActionResult> SaveStyle(string name, [FromBody] JsonObject style, CancellationToken ct)
|
||||
{
|
||||
await styles.SaveMineAsync(SessionUser.GetId(User), name, style, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("styles/{name}")]
|
||||
public async Task<IActionResult> DeleteStyle(string name, CancellationToken ct)
|
||||
{
|
||||
await styles.DeleteMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("usage")]
|
||||
public async Task<IActionResult> GetUsage([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
{
|
||||
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var start = from ?? end.AddDays(-6);
|
||||
if (start > end)
|
||||
{
|
||||
throw new DomainValidationException("'from' must be on or before 'to'.");
|
||||
}
|
||||
|
||||
return Ok(await usage.GetAsync(SessionUser.GetId(User), start, end, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateTokenRequest(string? Name);
|
||||
|
||||
public sealed record CloneStyleRequest(string Name, string From);
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Application.Extracts;
|
||||
using TileServer.Application.Tiles;
|
||||
using TileServer.Domain.Exceptions;
|
||||
using TileServer.Domain.Tiles;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("u/{slug}")]
|
||||
public sealed class PersonalDeliveryController(
|
||||
IApiTokenService tokens,
|
||||
ITileQueryService tiles,
|
||||
IUserStyleService styles,
|
||||
IExtractCatalog extracts,
|
||||
IUsageRecorder usage,
|
||||
IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
[HttpGet("tiles/{source}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
public async Task<IActionResult> GetTile(string slug, string source, int z, int x, int y, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var result = await tiles.GetTileAsync(source, new TileCoordinate(z, x, y), ct).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
usage.RecordTile(access.User.Id, access.Token.Id, 0);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var payload = result.Tile.Uncompressed();
|
||||
usage.RecordTile(access.User.Id, access.Token.Id, payload.Length);
|
||||
Response.Headers.CacheControl = "private, max-age=604800, immutable";
|
||||
return File(payload, "application/vnd.mapbox-vector-tile");
|
||||
}
|
||||
|
||||
[HttpGet("tiles/{source}.json")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> GetTileJson(string slug, string source, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var tilesUrl =
|
||||
$"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{source}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}";
|
||||
return Ok(tiles.GetTileJson(source, tilesUrl));
|
||||
}
|
||||
|
||||
[HttpGet("styles")]
|
||||
public async Task<IActionResult> ListStyles(string slug, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var list = await styles.ListForDeliveryAsync(
|
||||
access.User.Slug,
|
||||
PublicUrl.GetBase(Request, options),
|
||||
token,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpGet("styles/{name}")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> GetStyle(string slug, string name, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var extract = string.IsNullOrWhiteSpace(source)
|
||||
? extracts.GetAll().FirstOrDefault(e => e.Enabled)
|
||||
: extracts.Find(source);
|
||||
if (extract is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Extract", source ?? "(none configured)");
|
||||
}
|
||||
|
||||
var tilesUrl =
|
||||
$"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{extract.Id}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}";
|
||||
var style = await styles.GetForDeliveryAsync(access.User.Slug, name, extract.Id, tilesUrl, ct)
|
||||
.ConfigureAwait(false);
|
||||
var json = style.ToJsonString();
|
||||
usage.RecordStyle(access.User.Id, access.Token.Id, Encoding.UTF8.GetByteCount(json));
|
||||
return new JsonResult(style);
|
||||
}
|
||||
|
||||
private Task<ResolvedAccess> ResolveAsync(string slug, CancellationToken ct)
|
||||
=> tokens.ResolveAsync(slug, AccessToken.Read(Request), bearer: null, ct);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Application.Extracts;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/sources")]
|
||||
public sealed class SourcesController(IExtractService extracts, IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExtractResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(CancellationToken ct)
|
||||
=> Ok(await extracts.ListAsync(PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken ct)
|
||||
=> Ok(await extracts.GetAsync(id, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Upsert(string id, [FromBody] CreateExtractRequest request, CancellationToken ct)
|
||||
{
|
||||
var payload = request with { Id = string.IsNullOrWhiteSpace(request.Id) ? id : request.Id };
|
||||
return Ok(await extracts.AddOrUpdateAsync(payload, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken ct)
|
||||
{
|
||||
await extracts.RemoveAsync(id, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/styles")]
|
||||
public sealed class StylesController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult List() => Closed();
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public IActionResult Get(string name, [FromQuery] string? source) => Closed();
|
||||
|
||||
[HttpPut("{name}")]
|
||||
public IActionResult Put(string name, [FromBody] JsonObject style) => Closed();
|
||||
|
||||
private static IActionResult Closed()
|
||||
=> PublicDelivery.Closed("Public styles are disabled. Use /u/{slug}/styles/{name}?token= or POST /api/v1/me/styles while signed in.");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Application.Sync;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/sync")]
|
||||
public sealed class SyncController(IMapSyncService sync, ILogger<SyncController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
[ProducesResponseType(typeof(SyncJobStatus), StatusCodes.Status200OK)]
|
||||
public IActionResult Status() => Ok(sync.GetStatus());
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public IActionResult RunAll()
|
||||
=> Start(() => sync.RunAllAsync(CancellationToken.None), "Synchronization started for all extracts.");
|
||||
|
||||
[HttpPost("{extractId}")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public IActionResult RunOne(string extractId)
|
||||
=> Start(() => sync.RunOneAsync(extractId, CancellationToken.None), $"Synchronization started for '{extractId}'.");
|
||||
|
||||
private IActionResult Start(Func<Task> work, string message)
|
||||
{
|
||||
if (sync.GetStatus().IsRunning)
|
||||
{
|
||||
return Conflict(new { message = "A synchronization job is already running." });
|
||||
}
|
||||
|
||||
_ = RunInBackground(work);
|
||||
return Accepted(new { message });
|
||||
}
|
||||
|
||||
private async Task RunInBackground(Func<Task> work)
|
||||
{
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(false);
|
||||
}
|
||||
catch (ResourceConflictException)
|
||||
{
|
||||
logger.LogInformation("Sync request ignored because another job is running.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Background synchronization failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/tiles")]
|
||||
public sealed class TilesController : ControllerBase
|
||||
{
|
||||
[HttpGet("{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
[HttpGet("/tiles/{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
public IActionResult GetTile(string extractId, int z, int x, int y)
|
||||
=> Closed();
|
||||
|
||||
[HttpGet("{extractId}.json")]
|
||||
[HttpGet("/tiles/{extractId}.json")]
|
||||
public IActionResult GetTileJson(string extractId)
|
||||
=> Closed();
|
||||
|
||||
private static IActionResult Closed()
|
||||
=> PublicDelivery.Closed("Public tiles are disabled. Use /u/{slug}/tiles/{source}/{z}/{x}/{y}.pbf?token=");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class AccessToken
|
||||
{
|
||||
public static string? Read(HttpRequest request)
|
||||
{
|
||||
if (request.Query.TryGetValue("token", out var query) && !string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return query.ToString();
|
||||
}
|
||||
|
||||
var header = request.Headers.Authorization.ToString();
|
||||
const string bearer = "Bearer ";
|
||||
return header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
internal static class PublicDelivery
|
||||
{
|
||||
public static IActionResult Closed(string detail)
|
||||
=> new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = detail
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Application.Configuration;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class PublicUrl
|
||||
{
|
||||
public static string GetBase(HttpRequest request, IOptions<TileServerOptions> options)
|
||||
=> GetBase(request, options.Value.PublicBaseUrl);
|
||||
|
||||
public static string GetBase(HttpRequest request, string? publicBaseUrl)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||||
{
|
||||
return publicBaseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
var scheme = FirstForwarded(request.Headers["X-Forwarded-Proto"]) ?? request.Scheme;
|
||||
var host = FirstForwarded(request.Headers["X-Forwarded-Host"]) ?? request.Host.Value;
|
||||
var pathBase = request.PathBase.HasValue
|
||||
? request.PathBase.Value!.TrimEnd('/')
|
||||
: string.Empty;
|
||||
|
||||
return $"{scheme}://{host}{pathBase}";
|
||||
}
|
||||
|
||||
private static string? FirstForwarded(string? header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = header.Split(',')[0].Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class SessionUser
|
||||
{
|
||||
public static Guid GetId(ClaimsPrincipal user)
|
||||
{
|
||||
var raw = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!Guid.TryParse(raw, out var id))
|
||||
{
|
||||
throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Middleware;
|
||||
|
||||
public sealed class DomainExceptionHandler(ILogger<DomainExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, title) = exception switch
|
||||
{
|
||||
ResourceNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
|
||||
DomainValidationException => (HttpStatusCode.BadRequest, "Validation failed"),
|
||||
ResourceConflictException => (HttpStatusCode.Conflict, "Conflict"),
|
||||
UnauthenticatedException => (HttpStatusCode.Unauthorized, "Unauthorized"),
|
||||
_ => (HttpStatusCode.InternalServerError, "Unexpected error")
|
||||
};
|
||||
|
||||
if (status == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
logger.LogError(exception, "Unhandled exception");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(exception, "Domain exception mapped to {Status}", (int)status);
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = (int)status,
|
||||
Title = title,
|
||||
Detail = status == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : exception.Message,
|
||||
Instance = httpContext.Request.Path
|
||||
};
|
||||
|
||||
httpContext.Response.StatusCode = (int)status;
|
||||
if (status == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
}
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using TileServer.Api.Middleware;
|
||||
using TileServer.Application;
|
||||
using TileServer.Infrastructure;
|
||||
using TileServer.Infrastructure.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services
|
||||
.AddApplication()
|
||||
.AddInfrastructure(builder.Configuration);
|
||||
|
||||
var dataDirectory = builder.Configuration["TileServer:DataDirectory"] ?? "data";
|
||||
var keyPath = Path.Combine(dataDirectory, "aspnet-keys");
|
||||
Directory.CreateDirectory(keyPath);
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.SetApplicationName("TileServer")
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
|
||||
| ForwardedHeaders.XForwardedProto
|
||||
| ForwardedHeaders.XForwardedHost;
|
||||
// Docker / nginx source IPs are not in the default known-proxy list.
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
builder.Services.AddResponseCaching();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "Tile Server API",
|
||||
Version = "v1",
|
||||
Description = "Vector tiles, MapLibre styles and OSM extract synchronization."
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.WithMethods("GET", "HEAD", "PUT", "POST", "DELETE"));
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = "ts_session";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRedirectToAccessDenied = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services
|
||||
.AddControllersWithViews()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
var spaRoot = Path.Combine(app.Environment.WebRootPath, "app");
|
||||
if (Directory.Exists(spaRoot))
|
||||
{
|
||||
var spaFiles = new PhysicalFileProvider(spaRoot);
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = spaFiles });
|
||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = spaFiles });
|
||||
}
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseResponseCaching();
|
||||
app.UseCors();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health/live");
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
if (File.Exists(Path.Combine(spaRoot, "index.html")))
|
||||
{
|
||||
app.MapFallbackToFile("app/index.html");
|
||||
}
|
||||
else
|
||||
{
|
||||
app.MapGet("/", () => Results.Redirect("/demo"));
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "",
|
||||
"applicationUrl": "http://localhost:5088",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TileServer.Application\TileServer.Application.csproj" />
|
||||
<ProjectReference Include="..\TileServer.Infrastructure\TileServer.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\TileServer.Infrastructure\Seed\styles\*.json" Link="Seed\styles\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@TileServer.Api_HostAddress = http://localhost:5005
|
||||
|
||||
GET {{TileServer.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tile Server</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
|
||||
<link rel="stylesheet" href="~/css/demo.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map" role="application" aria-label="Карта"></div>
|
||||
|
||||
<header class="hud hud--top">
|
||||
<div class="brand">
|
||||
<span class="brand__mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.95"/>
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.28"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand__text">
|
||||
<strong>Tile Server</strong>
|
||||
<span>OSM · MapLibre · Geofabrik</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="field field--inline">
|
||||
<span>Источник</span>
|
||||
<select id="source-select"></select>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
<span>Стиль</span>
|
||||
<select id="style-select"></select>
|
||||
</label>
|
||||
<span class="pill" id="status-pill">—</span>
|
||||
<button type="button" class="btn btn--accent" id="sync-button">Синхронизировать</button>
|
||||
<a class="link" href="/swagger" target="_blank" rel="noreferrer">OpenAPI</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="hud hud--goto" id="goto-form">
|
||||
<div class="goto__head">
|
||||
<div>
|
||||
<h1>Камера</h1>
|
||||
<p class="coords" id="live-coords">—</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="presets" role="group" aria-label="Быстрый переход">
|
||||
<button type="button" class="preset" data-lat="55.7558" data-lon="37.6173" data-zoom="14">Москва</button>
|
||||
<button type="button" class="preset" data-lat="54.2255" data-lon="38.4692" data-zoom="6">ЦФО</button>
|
||||
<button type="button" class="preset preset--ghost" id="fit-bounds">По выгрузке</button>
|
||||
</div>
|
||||
<div class="goto__row">
|
||||
<label class="field">
|
||||
<span>Широта</span>
|
||||
<input id="goto-lat" name="lat" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="55.7558" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Долгота</span>
|
||||
<input id="goto-lon" name="lon" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="37.6173" required />
|
||||
</label>
|
||||
<label class="field field--zoom">
|
||||
<span>Зум</span>
|
||||
<input id="goto-zoom" name="zoom" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="14" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--accent btn--block">Перейти</button>
|
||||
<p class="error" id="goto-error" hidden></p>
|
||||
</form>
|
||||
|
||||
<aside class="hud hud--status" id="worker-panel">
|
||||
<details>
|
||||
<summary>
|
||||
<span class="worker__pulse" aria-hidden="true"></span>
|
||||
Воркер
|
||||
</summary>
|
||||
<dl>
|
||||
<div><dt>Задача</dt><dd id="sync-running">—</dd></div>
|
||||
<div><dt>Источник</dt><dd id="source-state">—</dd></div>
|
||||
<div><dt>Скачан</dt><dd id="source-downloaded">—</dd></div>
|
||||
<div><dt>Собран</dt><dd id="source-built">—</dd></div>
|
||||
<div><dt>Следующий запуск</dt><dd id="sync-next">—</dd></div>
|
||||
</dl>
|
||||
<p class="error" id="sync-error" hidden></p>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
|
||||
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
|
||||
<script src="~/js/demo.js" asp-append-version="true"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Accounts": "Host=127.0.0.1;Port=5433;Database=tile_server;Username=tiles;Password=tiles"
|
||||
},
|
||||
"TileServer": {
|
||||
"DataDirectory": "../../data",
|
||||
"JavaPath": "java",
|
||||
"JvmMaxHeap": "8g",
|
||||
"PlanetilerJarUrl": "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar",
|
||||
"PlanetilerHttpTimeout": "15m",
|
||||
"DownloadStallTimeoutSeconds": 45,
|
||||
"NaturalEarthUrls": [
|
||||
"https://naciscdn.org/naturalearth/packages/natural_earth_vector.sqlite.zip",
|
||||
"https://naturalearth.s3.amazonaws.com/packages/natural_earth_vector.sqlite.zip"
|
||||
],
|
||||
"LakeCenterlinesUrls": [
|
||||
"https://github.com/acalcutt/osm-lakelines/releases/download/latest/lake_centerline.shp.zip",
|
||||
"https://acalcutt.github.io/osm-lakelines/lake_centerline.shp.zip"
|
||||
],
|
||||
"WaterPolygonsUrls": [
|
||||
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip"
|
||||
],
|
||||
"DefaultMinZoom": 0,
|
||||
"DefaultMaxZoom": 15,
|
||||
"OverzoomMaxZoom": 18,
|
||||
"GlyphsUrl": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"Yandex": {
|
||||
"RedirectUri": "https://tile-server.ru"
|
||||
},
|
||||
"Sync": {
|
||||
"Cron": "0 3 * * *",
|
||||
"TimeZone": "UTC",
|
||||
"RunOnStartup": true,
|
||||
"RebuildIfTilesMissing": true
|
||||
},
|
||||
"Extracts": [
|
||||
{
|
||||
"Id": "central-fed-district",
|
||||
"Name": "Центральный федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf",
|
||||
"Center": [ 37.6173, 55.7558 ],
|
||||
"MinZoom": 0,
|
||||
"MaxZoom": 15,
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Id": "volga-fed-district",
|
||||
"Name": "Волго-Вятский федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/volga-fed-district-latest.osm.pbf",
|
||||
"Center": [ 45.0000, 57.0000 ],
|
||||
"MinZoom": 0,
|
||||
"MaxZoom": 15,
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: rgba(14, 18, 26, 0.78);
|
||||
--panel-strong: rgba(10, 13, 18, 0.92);
|
||||
--text: #f4f1ea;
|
||||
--muted: #9aa6b4;
|
||||
--line: rgba(244, 241, 234, 0.1);
|
||||
--line-strong: rgba(244, 241, 234, 0.16);
|
||||
--accent: #e8923d;
|
||||
--accent-press: #f3a85a;
|
||||
--accent-ink: #1a1208;
|
||||
--danger: #ff8a7a;
|
||||
--ok: #7dcea0;
|
||||
--warn: #f5c16c;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
||||
--hud-z: 3;
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
||||
--space: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html, body, #map {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 42%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(22px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.35);
|
||||
}
|
||||
|
||||
.hud--top {
|
||||
top: var(--space);
|
||||
left: var(--space);
|
||||
max-width: min(70rem, calc(100vw - 4.5rem));
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.85rem 1rem;
|
||||
padding: 0.7rem 0.8rem 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem 0.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 11px;
|
||||
color: var(--accent);
|
||||
background: rgba(232, 146, 61, 0.12);
|
||||
border: 1px solid rgba(232, 146, 61, 0.28);
|
||||
}
|
||||
|
||||
.brand__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.brand__text span {
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.55rem 0.65rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 10.5rem; }
|
||||
|
||||
.field--zoom { min-width: 0; }
|
||||
|
||||
select, button, input {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
color: var(--text);
|
||||
padding: 0.48rem 0.7rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
select, input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
|
||||
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% - 3px),
|
||||
calc(100% - 11px) calc(50% - 3px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 1.7rem;
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.btn--accent,
|
||||
#sync-button,
|
||||
.hud--goto button[type="submit"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: 0 8px 20px rgba(232, 146, 61, 0.22);
|
||||
}
|
||||
|
||||
.btn--accent:hover,
|
||||
#sync-button:hover,
|
||||
.hud--goto button[type="submit"]:hover {
|
||||
background: var(--accent-press);
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
.preset,
|
||||
.hud--status summary,
|
||||
.preset--ghost {
|
||||
background: rgba(8, 11, 16, 0.55);
|
||||
color: var(--text);
|
||||
font-weight: 550;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.preset:hover,
|
||||
.preset--ghost:hover,
|
||||
.hud--status summary:hover {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
align-self: center;
|
||||
padding: 0.45rem 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover { color: var(--accent); }
|
||||
|
||||
.pill {
|
||||
align-self: end;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.42rem 0.75rem 0.42rem 0.65rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill::before {
|
||||
content: "";
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 4px rgba(154, 166, 180, 0.12);
|
||||
}
|
||||
|
||||
.pill--ok { color: var(--ok); border-color: rgba(125, 206, 160, 0.32); }
|
||||
.pill--ok::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(125, 206, 160, 0.16); }
|
||||
.pill--work { color: var(--warn); border-color: rgba(245, 193, 108, 0.32); }
|
||||
.pill--work::before { background: var(--warn); box-shadow: 0 0 0 4px rgba(245, 193, 108, 0.16); }
|
||||
.pill--fail { color: var(--danger); border-color: rgba(255, 138, 122, 0.4); }
|
||||
.pill--fail::before { background: var(--danger); box-shadow: 0 0 0 4px rgba(255, 138, 122, 0.16); }
|
||||
|
||||
.hud--goto {
|
||||
left: var(--space);
|
||||
bottom: 1.75rem;
|
||||
width: min(22.75rem, calc(100vw - 24px));
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.goto__head h1 {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.coords {
|
||||
margin: 0.28rem 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.coords__sep {
|
||||
color: var(--muted);
|
||||
margin: 0 0.35rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.presets button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.goto__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 4.8rem;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 11, 16, 0.45);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.goto__row input {
|
||||
min-height: 2.5rem;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.goto__row input:focus {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
background: rgba(232, 146, 61, 0.06);
|
||||
}
|
||||
|
||||
.hud--status {
|
||||
right: var(--space);
|
||||
bottom: 3.4rem;
|
||||
top: auto;
|
||||
width: min(19.5rem, calc(100vw - 24px));
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud--status summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hud--status summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.worker__pulse {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.hud--status.is-running .worker__pulse {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.7);
|
||||
animation: pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
.hud--status.is-ready .worker__pulse {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.hud--status dl,
|
||||
.hud--status .error {
|
||||
padding: 0 0.9rem 0.85rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
dt { color: var(--muted); }
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--danger);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
left: var(--space);
|
||||
top: 5.4rem;
|
||||
max-width: min(34rem, calc(100vw - 24px));
|
||||
background: rgba(42, 31, 18, 0.92);
|
||||
color: #ffe0c2;
|
||||
border: 1px solid #6a4a22;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
a:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-top-right { top: var(--space); right: var(--space); }
|
||||
.maplibregl-ctrl-bottom-right { right: var(--space); bottom: var(--space); }
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background: transparent;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
background: rgba(10, 13, 18, 0.62) !important; /* MapLibre default is opaque white */
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-scale {
|
||||
background: rgba(10, 13, 18, 0.62);
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.55); }
|
||||
100% { box-shadow: 0 0 0 10px rgba(245, 193, 108, 0); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hud--top {
|
||||
right: 3.6rem;
|
||||
max-width: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 8.5rem; flex: 1; }
|
||||
.hud--status { display: none; }
|
||||
.banner { top: auto; bottom: 13.5rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
scroll-behavior: auto;
|
||||
animation: none !important; /* third-party MapLibre fade + our pulse */
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
const sourceSelect = document.getElementById("source-select");
|
||||
const styleSelect = document.getElementById("style-select");
|
||||
const syncButton = document.getElementById("sync-button");
|
||||
const gotoForm = document.getElementById("goto-form");
|
||||
const gotoError = document.getElementById("goto-error");
|
||||
const latInput = document.getElementById("goto-lat");
|
||||
const lonInput = document.getElementById("goto-lon");
|
||||
const zoomInput = document.getElementById("goto-zoom");
|
||||
const liveCoords = document.getElementById("live-coords");
|
||||
const banner = document.getElementById("banner");
|
||||
const statusPill = document.getElementById("status-pill");
|
||||
|
||||
let map;
|
||||
let sources = [];
|
||||
let styles = [];
|
||||
let activeStyleKey = null;
|
||||
let suppressGotoSync = false;
|
||||
|
||||
function formatStatus(value) {
|
||||
const labels = {
|
||||
Pending: "ожидание",
|
||||
Downloading: "скачивание PBF",
|
||||
PreparingSources: "данные Planetiler",
|
||||
Building: "сборка тайлов",
|
||||
Ready: "готово",
|
||||
Failed: "ошибка"
|
||||
};
|
||||
return labels[value] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "нет";
|
||||
}
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function showBanner(text, visible) {
|
||||
banner.hidden = !visible;
|
||||
banner.textContent = text ?? "";
|
||||
}
|
||||
|
||||
function showGotoError(text) {
|
||||
gotoError.hidden = !text;
|
||||
gotoError.textContent = text ?? "";
|
||||
}
|
||||
|
||||
function parseNumber(value) {
|
||||
const parsed = Number(String(value).trim().replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function cameraFromHash() {
|
||||
const match = location.hash.match(/^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const zoom = Number(match[1]);
|
||||
const lat = Number(match[2]);
|
||||
const lon = Number(match[3]);
|
||||
if (![zoom, lat, lon].every(Number.isFinite)) {
|
||||
return null;
|
||||
}
|
||||
return { zoom, lat, lon };
|
||||
}
|
||||
|
||||
function isGotoInputFocused() {
|
||||
const el = document.activeElement;
|
||||
return el === latInput || el === lonInput || el === zoomInput;
|
||||
}
|
||||
|
||||
function isUninitializedCamera(center, zoom) {
|
||||
return zoom < 2 && Math.abs(center.lat) < 1 && Math.abs(center.lng) < 1;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function currentSource() {
|
||||
return sources.find((item) => item.id === sourceSelect.value) ?? sources[0];
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
liveCoords.innerHTML = `${center.lat.toFixed(5)}°<span class="coords__sep">·</span>${center.lng.toFixed(5)}°<span class="coords__sep">z</span>${zoom.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function syncGotoFromMap() {
|
||||
if (!map || suppressGotoSync || isGotoInputFocused()) {
|
||||
return;
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
if (isUninitializedCamera(center, zoom)) {
|
||||
return;
|
||||
}
|
||||
latInput.value = center.lat.toFixed(5);
|
||||
lonInput.value = center.lng.toFixed(5);
|
||||
zoomInput.value = zoom.toFixed(2);
|
||||
}
|
||||
|
||||
function flyTo(lat, lon, zoom) {
|
||||
showGotoError("");
|
||||
suppressGotoSync = true;
|
||||
map.flyTo({ center: [lon, lat], zoom });
|
||||
map.once("moveend", () => {
|
||||
suppressGotoSync = false;
|
||||
updateHud();
|
||||
syncGotoFromMap();
|
||||
});
|
||||
}
|
||||
|
||||
function readGoto() {
|
||||
const lat = parseNumber(latInput.value);
|
||||
const lon = parseNumber(lonInput.value);
|
||||
const zoom = parseNumber(zoomInput.value);
|
||||
const maxZoom = map.getMaxZoom();
|
||||
|
||||
if (lat === null || lat < -90 || lat > 90) {
|
||||
return { error: "Широта: число от -90 до 90." };
|
||||
}
|
||||
if (lon === null || lon < -180 || lon > 180) {
|
||||
return { error: "Долгота: число от -180 до 180." };
|
||||
}
|
||||
if (zoom === null || zoom < 0 || zoom > maxZoom) {
|
||||
return { error: `Зум: число от 0 до ${maxZoom}.` };
|
||||
}
|
||||
return { lat, lon, zoom };
|
||||
}
|
||||
|
||||
function bindMapEvents() {
|
||||
map.on("move", updateHud);
|
||||
map.on("moveend", syncGotoFromMap);
|
||||
map.on("load", () => {
|
||||
map.resize();
|
||||
updateHud();
|
||||
syncGotoFromMap();
|
||||
});
|
||||
map.on("error", (event) => {
|
||||
const message = event?.error?.message;
|
||||
if (message) {
|
||||
showBanner(message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStyle() {
|
||||
const source = currentSource();
|
||||
const styleName = styleSelect.value;
|
||||
if (!source || !styleName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleKey = `${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`;
|
||||
if (map && activeStyleKey === styleKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = await fetchJson(`/api/v1/styles/${encodeURIComponent(styleName)}?source=${encodeURIComponent(source.id)}`);
|
||||
const hashed = cameraFromHash();
|
||||
const center = hashed
|
||||
? [hashed.lon, hashed.lat]
|
||||
: (source.center ?? [37.6173, 55.7558]);
|
||||
const zoom = hashed ? hashed.zoom : 6;
|
||||
|
||||
if (!map) {
|
||||
map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
maxZoom: 18,
|
||||
hash: true,
|
||||
attributionControl: true,
|
||||
transformRequest: (url) => ({
|
||||
url: new URL(url, window.location.origin).href
|
||||
})
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
|
||||
bindMapEvents();
|
||||
} else {
|
||||
map.setStyle(style, { diff: false });
|
||||
}
|
||||
|
||||
activeStyleKey = styleKey;
|
||||
const ready = source.status === "Ready";
|
||||
showBanner(
|
||||
ready
|
||||
? ""
|
||||
: `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.`,
|
||||
!ready
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const source = currentSource();
|
||||
const status = source?.status;
|
||||
document.getElementById("source-state").textContent = formatStatus(status);
|
||||
document.getElementById("source-downloaded").textContent = formatDate(source?.downloadedAt);
|
||||
document.getElementById("source-built").textContent = formatDate(source?.builtAt);
|
||||
|
||||
statusPill.textContent = formatStatus(status);
|
||||
statusPill.className = "pill";
|
||||
if (status === "Ready") {
|
||||
statusPill.classList.add("pill--ok");
|
||||
} else if (status === "Failed") {
|
||||
statusPill.classList.add("pill--fail");
|
||||
} else {
|
||||
statusPill.classList.add("pill--work");
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [sourceList, styleList, sync] = await Promise.all([
|
||||
fetchJson("/api/v1/sources"),
|
||||
fetchJson("/api/v1/styles"),
|
||||
fetchJson("/api/v1/sync/status")
|
||||
]);
|
||||
|
||||
sources = sourceList;
|
||||
styles = styleList;
|
||||
|
||||
const selectedSource = sourceSelect.value;
|
||||
const selectedStyle = styleSelect.value;
|
||||
sourceSelect.innerHTML = sources.map((item) => `<option value="${item.id}">${item.name}</option>`).join("");
|
||||
styleSelect.innerHTML = styles.map((item) => `<option value="${item.name}">${item.name}</option>`).join("");
|
||||
if (sources.some((item) => item.id === selectedSource)) {
|
||||
sourceSelect.value = selectedSource;
|
||||
}
|
||||
if (styles.some((item) => item.name === selectedStyle)) {
|
||||
styleSelect.value = selectedStyle;
|
||||
} else if (styles.some((item) => item.name === "osm-bright")) {
|
||||
styleSelect.value = "osm-bright";
|
||||
}
|
||||
|
||||
document.getElementById("sync-running").textContent = sync.isRunning ? "идёт синхронизация" : "ожидание";
|
||||
document.getElementById("sync-next").textContent = formatDate(sync.nextScheduledAt);
|
||||
const error = document.getElementById("sync-error");
|
||||
error.hidden = !sync.lastError;
|
||||
error.textContent = sync.lastError ?? "";
|
||||
const workerPanel = document.getElementById("worker-panel");
|
||||
workerPanel.classList.toggle("is-running", Boolean(sync.isRunning));
|
||||
workerPanel.classList.toggle("is-ready", !sync.isRunning && currentSource()?.status === "Ready");
|
||||
renderStatus();
|
||||
await loadStyle();
|
||||
}
|
||||
|
||||
sourceSelect.addEventListener("change", async () => {
|
||||
renderStatus();
|
||||
await loadStyle();
|
||||
const source = currentSource();
|
||||
if (map && source?.center) {
|
||||
flyTo(source.center[1], source.center[0], Math.max(map.getZoom(), 6));
|
||||
}
|
||||
});
|
||||
styleSelect.addEventListener("change", () => loadStyle());
|
||||
gotoForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
if (!map) {
|
||||
showGotoError("Карта ещё не загружена.");
|
||||
return;
|
||||
}
|
||||
const result = readGoto();
|
||||
if (result.error) {
|
||||
showGotoError(result.error);
|
||||
return;
|
||||
}
|
||||
flyTo(result.lat, result.lon, result.zoom);
|
||||
});
|
||||
document.querySelectorAll(".preset").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
flyTo(Number(button.dataset.lat), Number(button.dataset.lon), Number(button.dataset.zoom));
|
||||
});
|
||||
});
|
||||
document.getElementById("fit-bounds").addEventListener("click", () => {
|
||||
const bounds = currentSource()?.bounds;
|
||||
if (!map || !bounds) {
|
||||
showGotoError("У источника ещё нет bounds.");
|
||||
return;
|
||||
}
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds.minLon, bounds.minLat],
|
||||
[bounds.maxLon, bounds.maxLat]
|
||||
],
|
||||
{ padding: 48, maxZoom: 10 }
|
||||
);
|
||||
});
|
||||
syncButton.addEventListener("click", async () => {
|
||||
const source = currentSource();
|
||||
syncButton.disabled = true;
|
||||
try {
|
||||
const url = source ? `/api/v1/sync/${encodeURIComponent(source.id)}` : "/api/v1/sync";
|
||||
await fetchJson(url, { method: "POST" });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showBanner(error.message, true);
|
||||
} finally {
|
||||
syncButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
refresh().catch((error) => showBanner(error.message, true));
|
||||
setInterval(() => {
|
||||
refresh().catch(() => undefined);
|
||||
}, 15000);
|
||||
@@ -0,0 +1,48 @@
|
||||
using TileServer.Domain.Accounts;
|
||||
|
||||
namespace TileServer.Application.Accounts;
|
||||
|
||||
public sealed record YandexProfile(string Id, string Login, string DisplayName);
|
||||
|
||||
public sealed record MeResponse(
|
||||
Guid Id,
|
||||
string Login,
|
||||
string DisplayName,
|
||||
string Slug,
|
||||
string YandexId);
|
||||
|
||||
public sealed record TokenListItem(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? RevokedAt);
|
||||
|
||||
public sealed record CreatedToken(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
string Token,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record UsageDayDto(
|
||||
DateOnly Date,
|
||||
long TileRequests,
|
||||
long StyleRequests,
|
||||
long Bytes);
|
||||
|
||||
public sealed record UserStyleListItem(
|
||||
string Name,
|
||||
string? CreatedFrom,
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsPreset);
|
||||
|
||||
public sealed record ResolvedAccess(User User, ApiToken Token);
|
||||
|
||||
public sealed record UsageDelta(
|
||||
Guid UserId,
|
||||
Guid TokenId,
|
||||
DateOnly Day,
|
||||
long TileRequests,
|
||||
long StyleRequests,
|
||||
long Bytes);
|
||||
@@ -0,0 +1,104 @@
|
||||
using TileServer.Domain.Accounts;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Application.Accounts;
|
||||
|
||||
public sealed class AccountService(
|
||||
IYandexOAuthClient yandex,
|
||||
IUserRepository users,
|
||||
IApiTokenRepository tokens) : IAccountService
|
||||
{
|
||||
public const string CabinetTokenName = "Кабинет";
|
||||
|
||||
public async Task<(MeResponse Me, string? CabinetToken)> SignInFromYandexAsync(
|
||||
string code,
|
||||
string redirectUri,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new DomainValidationException("OAuth code is required.");
|
||||
}
|
||||
|
||||
var profile = await yandex.ExchangeAsync(code.Trim(), redirectUri, ct).ConfigureAwait(false);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var user = await users.FindByYandexIdAsync(profile.Id, ct).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
YandexId = profile.Id,
|
||||
Login = profile.Login,
|
||||
DisplayName = string.IsNullOrWhiteSpace(profile.DisplayName) ? profile.Login : profile.DisplayName,
|
||||
Slug = await AllocateSlugAsync(profile.Login, exceptUserId: null, ct).ConfigureAwait(false),
|
||||
CreatedAt = now,
|
||||
LastLoginAt = now
|
||||
};
|
||||
await users.AddAsync(user, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Login = profile.Login;
|
||||
user.DisplayName = string.IsNullOrWhiteSpace(profile.DisplayName) ? profile.Login : profile.DisplayName;
|
||||
user.LastLoginAt = now;
|
||||
}
|
||||
|
||||
await users.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
string? cabinetToken = null;
|
||||
if (await tokens.CountActiveAsync(user.Id, ct).ConfigureAwait(false) == 0)
|
||||
{
|
||||
cabinetToken = await CreateCabinetTokenAsync(user.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return (Map(user), cabinetToken);
|
||||
}
|
||||
|
||||
public async Task<MeResponse> GetMeAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var user = await users.FindByIdAsync(userId, ct).ConfigureAwait(false)
|
||||
?? throw new ResourceNotFoundException("User", userId.ToString());
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
private async Task<string> AllocateSlugAsync(string login, Guid? exceptUserId, CancellationToken ct)
|
||||
{
|
||||
var baseSlug = AccountSlug.FromLogin(login);
|
||||
if (!await users.SlugExistsAsync(baseSlug, exceptUserId, ct).ConfigureAwait(false))
|
||||
{
|
||||
return baseSlug;
|
||||
}
|
||||
|
||||
for (var i = 2; i < 1000; i++)
|
||||
{
|
||||
var candidate = AccountSlug.WithSuffix(baseSlug, i);
|
||||
if (!await users.SlugExistsAsync(candidate, exceptUserId, ct).ConfigureAwait(false))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ResourceConflictException("Could not allocate a unique account slug.");
|
||||
}
|
||||
|
||||
private async Task<string> CreateCabinetTokenAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plaintext = ApiTokenHash.CreatePlaintext();
|
||||
var token = new ApiToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = CabinetTokenName,
|
||||
Hash = ApiTokenHash.Compute(plaintext),
|
||||
Prefix = ApiTokenHash.DisplayPrefix(plaintext),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await tokens.AddAsync(token, ct).ConfigureAwait(false);
|
||||
await tokens.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private static MeResponse Map(User user)
|
||||
=> new(user.Id, user.Login, user.DisplayName, user.Slug, user.YandexId);
|
||||
}
|
||||